fix(local-tool): Wails 主視窗改為 splash + redirect 到 Next.js 主 UI

根因:visiona-local/frontend/ 是從 edge-ai-platform 複製過來的 installer wizard
HTML/JS/CSS,整組沒清理。main.go 的 //go:embed all:frontend 把這堆 wizard
直接當 Wails 主視窗內容,使用者開啟 app 看到的就是 "Edge AI Platform Installer"
而不是 Next.js 主 UI。macOS dmg 版本也有同樣問題,只是之前驗證時沒開 Wails
視窗而是用瀏覽器直連 localhost:3721 所以沒抓到。

修法:把 visiona-local/frontend/ 重寫為極簡 splash:
- index.html:splash 畫面
- app.js:import GetServerStatus / GetServerURL binding,輪詢直到 server ready,
  window.location.replace(url + '/') 跳到 Next.js 主 UI
- style.css:splash 樣式

Next.js 主 UI 不使用任何 Wails JS binding(純 HTTP API),所以從 wails://
跳到 http://127.0.0.1:<port>/ 後功能完整可用。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-12 03:32:31 +08:00
parent 1c5866ed14
commit 570e040a67
3 changed files with 89 additions and 1258 deletions

View File

@ -1,540 +1,55 @@
// Edge AI Platform Installer — Wizard Controller v0.2 // visionA Local — splash / bootstrap
// 職責:等 Go server 起來,拿到 URL 後跳轉到 Next.js 主 UI
// ── i18n Dictionary ─────────────────────────────────────── import { GetServerStatus, GetServerURL } from './wailsjs/go/main/App.js';
const i18n = {
en: { const statusEl = document.getElementById('status');
'welcome.title': 'Edge AI Platform Installer', const errorEl = document.getElementById('error');
'welcome.subtitle': 'Set up your edge AI development environment with Kneron hardware support.',
'path.title': 'Installation Path', const POLL_INTERVAL_MS = 500;
'path.subtitle': 'Choose where to install Edge AI Platform.', const MAX_WAIT_MS = 60_000;
'path.browse': 'Browse', const startTime = Date.now();
'path.required': 'Installation path is required.',
'path.valid': 'Path is valid.', async function poll() {
'components.title': 'Select Components', if (Date.now() - startTime > MAX_WAIT_MS) {
'components.subtitle': 'Choose which components to install.', showError('伺服器啟動逾時60 秒),請檢查 log 或重新啟動應用程式。');
'components.server': 'Edge AI Server', return;
'components.serverDesc': 'Core server binary for hardware communication (~10 MB)',
'components.models': 'Kneron Models',
'components.modelsDesc': 'Pre-trained NEF model files for KL520/KL720 (~50 MB)',
'components.python': 'Python Environment',
'components.pythonDesc': 'Python venv with Kneron PLUS SDK and dependencies (~200 MB)',
'components.libusb': 'libusb',
'components.libusbDesc': 'USB library required for Kneron device communication',
'components.symlink': 'CLI Symlink',
'components.symlinkDesc': "Add 'edge-ai' command to /usr/local/bin",
'relay.title': 'Relay Configuration',
'relay.subtitle': 'Configure the relay server for remote access. You can skip this and configure later.',
'relay.url': 'Relay URL',
'relay.token': 'Relay Token',
'relay.port': 'Server Port',
'relay.hint': 'Leave empty to skip relay configuration. You can set this later in the config file.',
'progress.title': 'Installing...',
'progress.subtitle': 'Please wait while components are being installed.',
'progress.preparing': 'Preparing installation...',
'hardware.title': 'Hardware Detection',
'hardware.subtitle': 'Connect your Kneron devices and scan for hardware.',
'hardware.scanning': 'Scanning for devices...',
'hardware.noDevices': 'No Kneron devices found. Connect a device and try again.',
'hardware.rescan': 'Rescan',
'complete.title': 'Installation Complete',
'complete.subtitle': 'Edge AI Platform has been installed successfully.',
'complete.location': 'Install Location',
'complete.server': 'Edge AI Server',
'complete.models': 'Kneron Models',
'complete.python': 'Python Environment',
'complete.libusb': 'libusb',
'complete.installed': 'Installed',
'complete.skipped': 'Skipped',
'btn.next': 'Next',
'btn.back': 'Back',
'btn.install': 'Install',
'btn.launch': 'Launch Server',
'btn.openDashboard': 'Open Dashboard',
'btn.close': 'Close',
'relay.tokenHint': 'Auto-generated random token. Both the server and browser use this to authenticate with the relay.',
'relay.dashboardUrl': 'Dashboard URL',
'relay.dashboardHint': 'The HTTP URL to access the dashboard via relay. Opened after server launch.',
'existing.detected': 'Existing installation detected',
'existing.desc': 'An existing installation was found. You can uninstall it or install over it.',
'existing.uninstall': 'Uninstall',
'uninstall.title': 'Uninstalling...',
'uninstall.subtitle': 'Removing installed files.',
'uninstall.confirm': 'This will remove the Edge AI Platform and all installed files. Continue?',
'uninstall.complete': 'Uninstall complete. System dependencies (Python, libusb) were preserved.',
'system.platform': 'Platform',
'system.python': 'Python',
'system.libusb': 'libusb',
'system.ffmpeg': 'FFmpeg',
'status.installed': 'Installed',
'status.notFound': 'Not found',
'status.notInstalled': 'Not installed',
'status.optional': 'Not installed (optional)',
},
'zh-TW': {
'welcome.title': 'Edge AI 平台安裝程式',
'welcome.subtitle': '設定您的邊緣 AI 開發環境,支援 Kneron 硬體。',
'path.title': '安裝路徑',
'path.subtitle': '選擇 Edge AI 平台的安裝位置。',
'path.browse': '瀏覽',
'path.required': '安裝路徑為必填。',
'path.valid': '路徑有效。',
'components.title': '選擇元件',
'components.subtitle': '選擇要安裝的元件。',
'components.server': 'Edge AI 伺服器',
'components.serverDesc': '硬體通訊核心伺服器程式 (~10 MB)',
'components.models': 'Kneron 模型',
'components.modelsDesc': 'KL520/KL720 預訓練 NEF 模型檔案 (~50 MB)',
'components.python': 'Python 環境',
'components.pythonDesc': '包含 Kneron PLUS SDK 的 Python 虛擬環境 (~200 MB)',
'components.libusb': 'libusb',
'components.libusbDesc': 'Kneron 裝置通訊所需的 USB 函式庫',
'components.symlink': 'CLI 捷徑',
'components.symlinkDesc': "新增 'edge-ai' 指令到 /usr/local/bin",
'relay.title': 'Relay 設定',
'relay.subtitle': '設定 Relay 伺服器以進行遠端存取。可以跳過稍後再設定。',
'relay.url': 'Relay URL',
'relay.token': 'Relay Token',
'relay.port': '伺服器連接埠',
'relay.hint': '留空可跳過 Relay 設定,稍後可在設定檔中修改。',
'progress.title': '安裝中...',
'progress.subtitle': '正在安裝元件,請稍候。',
'progress.preparing': '準備安裝中...',
'hardware.title': '硬體偵測',
'hardware.subtitle': '連接您的 Kneron 裝置並掃描硬體。',
'hardware.scanning': '正在掃描裝置...',
'hardware.noDevices': '未偵測到 Kneron 裝置。請連接裝置後再試。',
'hardware.rescan': '重新掃描',
'complete.title': '安裝完成',
'complete.subtitle': 'Edge AI 平台已成功安裝。',
'complete.location': '安裝位置',
'complete.server': 'Edge AI 伺服器',
'complete.models': 'Kneron 模型',
'complete.python': 'Python 環境',
'complete.libusb': 'libusb',
'complete.installed': '已安裝',
'complete.skipped': '已跳過',
'btn.next': '下一步',
'btn.back': '上一步',
'btn.install': '安裝',
'btn.launch': '啟動伺服器',
'btn.openDashboard': '開啟控制台',
'btn.close': '關閉',
'relay.tokenHint': '自動產生的隨機 Token。伺服器和瀏覽器都透過此 Token 向 Relay 驗證身份。',
'relay.dashboardUrl': 'Dashboard URL',
'relay.dashboardHint': '透過 Relay 存取 Dashboard 的 HTTP URL。啟動伺服器後會自動開啟。',
'existing.detected': '偵測到既有安裝',
'existing.desc': '發現既有安裝。您可以解除安裝或覆蓋安裝。',
'existing.uninstall': '解除安裝',
'uninstall.title': '解除安裝中...',
'uninstall.subtitle': '正在移除已安裝的檔案。',
'uninstall.confirm': '這將移除 Edge AI 平台及所有已安裝的檔案。是否繼續?',
'uninstall.complete': '解除安裝完成。系統相依套件Python、libusb已保留。',
'system.platform': '平台',
'system.python': 'Python',
'system.libusb': 'libusb',
'system.ffmpeg': 'FFmpeg',
'status.installed': '已安裝',
'status.notFound': '未找到',
'status.notInstalled': '未安裝',
'status.optional': '未安裝(選用)',
} }
};
// ── State ─────────────────────────────────────────────────
let currentStep = 0;
let systemInfo = null;
let installConfig = {
installDir: '',
createSymlink: true,
installPythonEnv: true,
installLibusb: true,
relayURL: '',
relayToken: '',
dashboardURL: '',
serverPort: 3721,
language: 'en',
};
// ── i18n Functions ────────────────────────────────────────
function t(key) {
const dict = i18n[installConfig.language] || i18n.en;
return dict[key] || i18n.en[key] || key;
}
function setLanguage(lang) {
installConfig.language = lang;
// Update active button state
document.getElementById('lang-en').classList.toggle('active', lang === 'en');
document.getElementById('lang-zh').classList.toggle('active', lang === 'zh-TW');
// Update all elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const text = t(key);
if (text) {
el.textContent = text;
}
});
// Update html lang attribute
document.documentElement.lang = lang === 'zh-TW' ? 'zh-TW' : 'en';
}
// ── Step Navigation ───────────────────────────────────────
function showStep(n) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.step-dot').forEach((d, i) => {
d.classList.remove('active', 'completed');
if (i < n) d.classList.add('completed');
if (i === n) d.classList.add('active');
});
const step = document.getElementById('step-' + n);
if (step) step.classList.add('active');
currentStep = n;
}
// ── Step 0: Welcome ───────────────────────────────────────
async function initWelcome() {
try {
systemInfo = await window.go.main.Installer.GetSystemInfo();
document.getElementById('info-platform').textContent =
systemInfo.os + ' / ' + systemInfo.arch;
const pyEl = document.getElementById('info-python');
if (systemInfo.pythonAvailable) {
pyEl.textContent = systemInfo.pythonVersion;
pyEl.className = 'info-value status-ok';
} else {
pyEl.textContent = t('status.notFound');
pyEl.className = 'info-value status-warn';
}
const luEl = document.getElementById('info-libusb');
luEl.textContent = systemInfo.libusbInstalled ? t('status.installed') : t('status.notInstalled');
luEl.className = 'info-value ' + (systemInfo.libusbInstalled ? 'status-ok' : 'status-warn');
const ffEl = document.getElementById('info-ffmpeg');
ffEl.textContent = systemInfo.ffmpegAvailable ? t('status.installed') : t('status.optional');
ffEl.className = 'info-value ' + (systemInfo.ffmpegAvailable ? 'status-ok' : '');
installConfig.installDir = systemInfo.defaultDir;
if (systemInfo.existingInstall) {
document.getElementById('existing-install').style.display = 'block';
}
} catch (err) {
console.error('GetSystemInfo failed:', err);
}
}
// ── Step 1: Path ──────────────────────────────────────────
document.getElementById('btn-browse').addEventListener('click', async () => {
try {
const dir = await window.go.main.Installer.BrowseDirectory();
if (dir) {
document.getElementById('install-path').value = dir;
installConfig.installDir = dir;
const msg = await window.go.main.Installer.ValidatePath(dir);
const statusEl = document.getElementById('path-status');
if (msg) {
statusEl.textContent = msg;
statusEl.className = 'status-text error';
} else {
statusEl.textContent = t('path.valid');
statusEl.className = 'status-text';
}
}
} catch (err) {
console.error('BrowseDirectory failed:', err);
}
});
// ── Step 4: Install Progress ──────────────────────────────
function addLogLine(message, type) {
const log = document.getElementById('progress-log');
const line = document.createElement('div');
line.className = 'log-' + (type || 'line');
line.textContent = message;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
async function startInstall() {
showStep(4);
document.getElementById('progress-title').textContent = t('progress.title');
document.getElementById('progress-subtitle').textContent = t('progress.subtitle');
document.getElementById('progress-log').innerHTML = '';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-percent').textContent = '0%';
document.getElementById('progress-message').textContent = t('progress.preparing');
try { try {
await window.go.main.Installer.StartInstall(installConfig); const status = await GetServerStatus();
} catch (err) { if (status && status.lastError) {
addLogLine('Error: ' + err, 'error'); showError('伺服器啟動失敗:' + status.lastError);
}
}
if (window.runtime && window.runtime.EventsOn) {
window.runtime.EventsOn('install:progress', (event) => {
const fill = document.getElementById('progress-fill');
const percent = document.getElementById('progress-percent');
const message = document.getElementById('progress-message');
fill.style.width = event.percent + '%';
percent.textContent = Math.round(event.percent) + '%';
message.textContent = event.message;
addLogLine(event.message, event.isError ? 'error' : (event.isComplete ? 'success' : 'line'));
if (event.isComplete && !event.isError) {
setTimeout(() => {
showStep(5);
detectHardware();
}, 500);
}
});
window.runtime.EventsOn('uninstall:progress', (event) => {
const message = document.getElementById('progress-message');
const fill = document.getElementById('progress-fill');
const percent = document.getElementById('progress-percent');
fill.style.width = event.percent + '%';
percent.textContent = Math.round(event.percent) + '%';
message.textContent = event.message;
addLogLine(event.message, event.isError ? 'error' : 'line');
if (event.isComplete) {
document.getElementById('progress-title').textContent = t('uninstall.title').replace('...', '');
document.getElementById('progress-subtitle').textContent = t('uninstall.complete');
addLogLine(t('uninstall.complete'), 'success');
}
});
}
// ── Step 5: Hardware Detection ────────────────────────────
async function detectHardware() {
const el = document.getElementById('hardware-results');
el.innerHTML =
'<div class="device-scanning">' +
'<div class="spinner"></div>' +
'<p>' + t('hardware.scanning') + '</p>' +
'</div>';
try {
const devices = await window.go.main.Installer.DetectHardware();
if (!devices || devices.length === 0) {
el.innerHTML = '<div class="no-devices"><p>' + t('hardware.noDevices') + '</p></div>';
} else {
el.innerHTML = devices.map(d =>
'<div class="device-card">' +
'<div class="device-icon">&#x2B21;</div>' +
'<div class="device-info">' +
'<span class="device-name">Kneron ' + (d.model || 'Unknown') + '</span>' +
'<span class="device-detail">' + (d.product || d.port || '') + '</span>' +
'</div>' +
'</div>'
).join('');
}
} catch (err) {
el.innerHTML = '<div class="no-devices"><p>Detection skipped: ' + err + '</p></div>';
}
}
document.getElementById('btn-rescan').addEventListener('click', () => {
detectHardware();
});
// ── Step 6: Complete ──────────────────────────────────────
document.getElementById('btn-launch').addEventListener('click', async () => {
try {
await window.go.main.Installer.LaunchServer();
// After launching, if dashboard URL is configured, open it automatically
const dashUrl = installConfig.dashboardURL || await window.go.main.Installer.GetDashboardURL();
if (dashUrl) {
// Give the server a moment to start, then open dashboard
setTimeout(async () => {
// Append relay token as query param if available
let url = dashUrl;
if (installConfig.relayToken) {
const sep = url.includes('?') ? '&' : '?';
url = url + sep + 'token=' + encodeURIComponent(installConfig.relayToken);
}
await window.go.main.Installer.OpenBrowser(url);
}, 2000);
} else {
// No relay — open local
setTimeout(async () => {
const port = installConfig.serverPort || 3721;
await window.go.main.Installer.OpenBrowser('http://127.0.0.1:' + port);
}, 2000);
}
} catch (err) {
alert('Failed to launch: ' + err);
}
});
document.getElementById('btn-open-dashboard').addEventListener('click', async () => {
const dashUrl = installConfig.dashboardURL || await window.go.main.Installer.GetDashboardURL();
if (dashUrl) {
let url = dashUrl;
if (installConfig.relayToken) {
const sep = url.includes('?') ? '&' : '?';
url = url + sep + 'token=' + encodeURIComponent(installConfig.relayToken);
}
await window.go.main.Installer.OpenBrowser(url);
}
});
document.getElementById('btn-close').addEventListener('click', () => {
if (window.runtime && window.runtime.Quit) {
window.runtime.Quit();
} else {
window.close();
}
});
// ── Uninstall ─────────────────────────────────────────────
document.getElementById('btn-uninstall').addEventListener('click', async () => {
try {
const confirmed = await window.go.main.Installer.ConfirmUninstall();
if (!confirmed) return;
} catch (err) {
// Fallback to JS confirm if native dialog fails
if (!confirm(t('uninstall.confirm'))) return;
}
showStep(4);
document.getElementById('progress-title').textContent = t('uninstall.title');
document.getElementById('progress-subtitle').textContent = t('uninstall.subtitle');
document.getElementById('progress-log').innerHTML = '';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-percent').textContent = '0%';
try {
await window.go.main.Installer.Uninstall();
} catch (err) {
addLogLine('Error: ' + err, 'error');
}
});
// ── Navigation Wiring ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Detect initial language from browser
const browserLang = navigator.language || navigator.userLanguage || 'en';
const initialLang = browserLang.startsWith('zh') ? 'zh-TW' : 'en';
setLanguage(initialLang);
initWelcome();
// Language switcher
document.getElementById('lang-en').addEventListener('click', () => setLanguage('en'));
document.getElementById('lang-zh').addEventListener('click', () => setLanguage('zh-TW'));
// Step 0 -> Step 1
document.getElementById('btn-next-0').addEventListener('click', () => {
showStep(1);
document.getElementById('install-path').value = installConfig.installDir;
});
// Step 1 Back -> Step 0
document.getElementById('btn-back-1').addEventListener('click', () => {
showStep(0);
});
// Step 1 -> Step 2
document.getElementById('btn-next-1').addEventListener('click', async () => {
const msg = await window.go.main.Installer.ValidatePath(installConfig.installDir);
if (msg) {
const statusEl = document.getElementById('path-status');
statusEl.textContent = msg;
statusEl.className = 'status-text error';
return; return;
} }
showStep(2); if (status && status.running && status.url) {
}); statusEl.textContent = '啟動完成,載入主介面...';
window.location.replace(status.url + '/');
// Step 2 Back -> Step 1 return;
document.getElementById('btn-back-2').addEventListener('click', () => {
showStep(1);
});
// Step 2 Install -> Step 3 (Relay Config)
document.getElementById('btn-install').addEventListener('click', async () => {
installConfig.createSymlink = document.getElementById('comp-symlink').checked;
installConfig.installPythonEnv = document.getElementById('comp-python').checked;
installConfig.installLibusb = document.getElementById('comp-libusb').checked;
showStep(3);
// Auto-generate relay token if empty
const tokenInput = document.getElementById('relay-token');
if (!tokenInput.value.trim()) {
try {
const token = await window.go.main.Installer.GenerateToken();
tokenInput.value = token;
} catch (err) {
console.error('GenerateToken failed:', err);
}
} }
});
// Step 3 Back -> Step 2 const url = await GetServerURL();
document.getElementById('btn-back-3').addEventListener('click', () => { if (url) {
showStep(2); statusEl.textContent = '啟動完成,載入主介面...';
}); window.location.replace(url + '/');
return;
// Regenerate token button
document.getElementById('btn-regen-token').addEventListener('click', async () => {
try {
const token = await window.go.main.Installer.GenerateToken();
document.getElementById('relay-token').value = token;
} catch (err) {
console.error('GenerateToken failed:', err);
} }
}); } catch (e) {
// binding 尚未就緒時會 throw忽略繼續輪詢
}
// Step 3 Next -> collect relay fields -> Step 4 (Progress) -> start install setTimeout(poll, POLL_INTERVAL_MS);
document.getElementById('btn-next-3').addEventListener('click', () => { }
installConfig.relayURL = document.getElementById('relay-url').value.trim();
installConfig.relayToken = document.getElementById('relay-token').value.trim();
installConfig.dashboardURL = document.getElementById('dashboard-url').value.trim();
const portVal = parseInt(document.getElementById('server-port').value, 10);
installConfig.serverPort = (portVal >= 1024 && portVal <= 65535) ? portVal : 3721;
startInstall();
});
// Step 5 Next -> Step 6 (Complete) function showError(msg) {
document.getElementById('btn-next-5').addEventListener('click', () => { statusEl.hidden = true;
showStep(6); errorEl.textContent = msg;
document.getElementById('summary-path').textContent = installConfig.installDir; errorEl.hidden = false;
}
const modelsEl = document.getElementById('summary-models'); // 等 Wails runtime 就緒再開始輪詢
modelsEl.textContent = t('complete.installed'); if (window.runtime) {
modelsEl.className = 'info-value status-ok'; poll();
} else {
const pyEl = document.getElementById('summary-python'); window.addEventListener('load', () => setTimeout(poll, 200));
pyEl.textContent = installConfig.installPythonEnv ? t('complete.installed') : t('complete.skipped'); }
pyEl.className = 'info-value ' + (installConfig.installPythonEnv ? 'status-ok' : 'status-skipped');
const luEl = document.getElementById('summary-libusb');
luEl.textContent = installConfig.installLibusb ? t('complete.installed') : t('complete.skipped');
luEl.className = 'info-value ' + (installConfig.installLibusb ? 'status-ok' : 'status-skipped');
// Show "Open Dashboard" button if relay dashboard URL is configured
if (installConfig.dashboardURL) {
document.getElementById('btn-open-dashboard').style.display = 'inline-flex';
}
});
});

View File

@ -3,256 +3,18 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge AI Platform Installer</title> <title>visionA Local</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<!-- Header --> <div class="splash">
<header id="wizard-header"> <div class="logo">visionA Local</div>
<div class="header-left"> <div class="spinner"></div>
<div class="logo-text" data-i18n="welcome.title">Edge AI Platform</div> <div class="status" id="status">正在啟動伺服器...</div>
<div class="version-text">Installer v0.2.0</div> <div class="error" id="error" hidden></div>
</div> </div>
<div class="header-center">
<div class="step-indicators">
<span class="step-dot active" data-step="0">1</span>
<span class="step-line"></span>
<span class="step-dot" data-step="1">2</span>
<span class="step-line"></span>
<span class="step-dot" data-step="2">3</span>
<span class="step-line"></span>
<span class="step-dot" data-step="3">4</span>
<span class="step-line"></span>
<span class="step-dot" data-step="4">5</span>
<span class="step-line"></span>
<span class="step-dot" data-step="5">6</span>
<span class="step-line"></span>
<span class="step-dot" data-step="6">7</span>
</div>
</div>
<div class="header-right">
<div class="lang-switch">
<button class="lang-btn active" id="lang-en">EN</button>
<span class="lang-sep">|</span>
<button class="lang-btn" id="lang-zh">中文</button>
</div>
</div>
</header>
<main>
<!-- Step 0: Welcome -->
<section id="step-0" class="step active">
<h1 data-i18n="welcome.title">Edge AI Platform Installer</h1>
<p class="subtitle" data-i18n="welcome.subtitle">Set up your edge AI development environment with Kneron hardware support.</p>
<div id="system-info" class="info-card">
<div class="info-row"><span class="info-label" data-i18n="system.platform">Platform</span><span id="info-platform" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="system.python">Python</span><span id="info-python" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="system.libusb">libusb</span><span id="info-libusb" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="system.ffmpeg">FFmpeg</span><span id="info-ffmpeg" class="info-value">-</span></div>
</div>
<div id="existing-install" class="warning-card" style="display:none">
<strong data-i18n="existing.detected">Existing installation detected</strong>
<p data-i18n="existing.desc">An existing installation was found. You can uninstall it or install over it.</p>
<p id="existing-path" class="existing-path"></p>
<button id="btn-uninstall" class="btn btn-danger" data-i18n="existing.uninstall">Uninstall</button>
</div>
<div class="actions">
<button id="btn-next-0" class="btn btn-primary" data-i18n="btn.next">Next</button>
</div>
</section>
<!-- Step 1: Install Path -->
<section id="step-1" class="step">
<h1 data-i18n="path.title">Installation Path</h1>
<p class="subtitle" data-i18n="path.subtitle">Choose where to install Edge AI Platform.</p>
<div class="form-group">
<div class="path-input-group">
<input type="text" id="install-path" class="input-field" readonly>
<button id="btn-browse" class="btn btn-secondary" data-i18n="path.browse">Browse</button>
</div>
<p id="path-status" class="status-text"></p>
</div>
<div class="actions">
<button id="btn-back-1" class="btn btn-ghost" data-i18n="btn.back">Back</button>
<button id="btn-next-1" class="btn btn-primary" data-i18n="btn.next">Next</button>
</div>
</section>
<!-- Step 2: Components -->
<section id="step-2" class="step">
<h1 data-i18n="components.title">Select Components</h1>
<p class="subtitle" data-i18n="components.subtitle">Choose which components to install.</p>
<div class="component-list">
<label class="component-item required">
<div class="component-check">
<input type="checkbox" id="comp-server" checked disabled>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.server">Edge AI Server</span>
<span class="component-desc" data-i18n="components.serverDesc">Core server binary for hardware communication (~10 MB)</span>
</div>
</label>
<label class="component-item">
<div class="component-check">
<input type="checkbox" id="comp-models" checked>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.models">Kneron Models</span>
<span class="component-desc" data-i18n="components.modelsDesc">Pre-trained NEF model files for KL520/KL720 (~50 MB)</span>
</div>
</label>
<label class="component-item">
<div class="component-check">
<input type="checkbox" id="comp-python" checked>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.python">Python Environment</span>
<span class="component-desc" data-i18n="components.pythonDesc">Python venv with Kneron PLUS SDK and dependencies (~200 MB)</span>
</div>
</label>
<label class="component-item">
<div class="component-check">
<input type="checkbox" id="comp-libusb" checked>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.libusb">libusb</span>
<span class="component-desc" data-i18n="components.libusbDesc">USB library required for Kneron device communication</span>
</div>
</label>
<label class="component-item" id="comp-symlink-row">
<div class="component-check">
<input type="checkbox" id="comp-symlink" checked>
<span class="checkmark"></span>
</div>
<div class="component-info">
<span class="component-name" data-i18n="components.symlink">CLI Symlink</span>
<span class="component-desc" data-i18n="components.symlinkDesc">Add 'edge-ai' command to /usr/local/bin</span>
</div>
</label>
</div>
<div class="actions">
<button id="btn-back-2" class="btn btn-ghost" data-i18n="btn.back">Back</button>
<button id="btn-install" class="btn btn-primary" data-i18n="btn.install">Install</button>
</div>
</section>
<!-- Step 3: Relay Configuration -->
<section id="step-3" class="step">
<h1 data-i18n="relay.title">Relay Configuration</h1>
<p class="subtitle" data-i18n="relay.subtitle">Configure the relay server for remote access. You can skip this and configure later.</p>
<div class="form-group">
<label class="field-label" data-i18n="relay.url">Relay URL</label>
<input type="text" class="input-field" id="relay-url" value="ws://ec2-13-192-170-7.ap-northeast-1.compute.amazonaws.com/tunnel/connect">
</div>
<div class="form-group">
<label class="field-label" data-i18n="relay.token">Relay Token</label>
<div class="path-input-group">
<input type="text" class="input-field" id="relay-token" placeholder="auto-generated" readonly>
<button id="btn-regen-token" class="btn btn-secondary" title="Regenerate">&#x21BB;</button>
</div>
<p class="field-hint" data-i18n="relay.tokenHint">Auto-generated random token. Both the server and browser use this to authenticate with the relay.</p>
</div>
<div class="form-group">
<label class="field-label" data-i18n="relay.dashboardUrl">Dashboard URL</label>
<input type="text" class="input-field" id="dashboard-url" value="http://ec2-13-192-170-7.ap-northeast-1.compute.amazonaws.com">
<p class="field-hint" data-i18n="relay.dashboardHint">The HTTP URL to access the dashboard via relay. Opened after server launch.</p>
</div>
<div class="form-group">
<label class="field-label" data-i18n="relay.port">Server Port</label>
<input type="number" class="input-field" id="server-port" value="3721" min="1024" max="65535">
</div>
<p class="field-hint" data-i18n="relay.hint">Leave empty to skip relay configuration. You can set this later in the config file.</p>
<div class="actions">
<button id="btn-back-3" class="btn btn-ghost" data-i18n="btn.back">Back</button>
<button id="btn-next-3" class="btn btn-primary" data-i18n="btn.next">Next</button>
</div>
</section>
<!-- Step 4: Progress -->
<section id="step-4" class="step">
<h1 id="progress-title" data-i18n="progress.title">Installing...</h1>
<p class="subtitle" id="progress-subtitle" data-i18n="progress.subtitle">Please wait while components are being installed.</p>
<div class="progress-container">
<div class="progress-bar">
<div id="progress-fill" class="progress-fill" style="width:0%"></div>
</div>
<span id="progress-percent" class="progress-percent">0%</span>
</div>
<p id="progress-message" class="progress-message" data-i18n="progress.preparing">Preparing installation...</p>
<div id="progress-log" class="log-area"></div>
</section>
<!-- Step 5: Hardware Detection -->
<section id="step-5" class="step">
<h1 data-i18n="hardware.title">Hardware Detection</h1>
<p class="subtitle" data-i18n="hardware.subtitle">Connect your Kneron devices and scan for hardware.</p>
<div id="hardware-results" class="hardware-list">
<div class="device-scanning" id="device-scanning">
<div class="spinner"></div>
<p data-i18n="hardware.scanning">Scanning for devices...</p>
</div>
<div class="no-devices" id="no-devices" style="display:none;">
<p data-i18n="hardware.noDevices">No Kneron devices found. Connect a device and try again.</p>
</div>
</div>
<div class="actions">
<button id="btn-rescan" class="btn btn-secondary" data-i18n="hardware.rescan">Rescan</button>
<button id="btn-next-5" class="btn btn-primary" data-i18n="btn.next">Next</button>
</div>
</section>
<!-- Step 6: Complete -->
<section id="step-6" class="step">
<div class="complete-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h1 data-i18n="complete.title">Installation Complete</h1>
<p class="subtitle" data-i18n="complete.subtitle">Edge AI Platform has been installed successfully.</p>
<div id="install-summary" class="info-card">
<div class="info-row"><span class="info-label" data-i18n="complete.location">Install Location</span><span id="summary-path" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="complete.server">Edge AI Server</span><span id="summary-server" class="info-value status-ok" data-i18n="complete.installed">Installed</span></div>
<div class="info-row"><span class="info-label" data-i18n="complete.models">Kneron Models</span><span id="summary-models" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="complete.python">Python Environment</span><span id="summary-python" class="info-value">-</span></div>
<div class="info-row"><span class="info-label" data-i18n="complete.libusb">libusb</span><span id="summary-libusb" class="info-value">-</span></div>
</div>
<div class="actions">
<button id="btn-launch" class="btn btn-primary" data-i18n="btn.launch">Launch Server</button>
<button id="btn-open-dashboard" class="btn btn-secondary" style="display:none" data-i18n="btn.openDashboard">Open Dashboard</button>
<button id="btn-close" class="btn btn-ghost" data-i18n="btn.close">Close</button>
</div>
</section>
</main>
</div> </div>
<script type="module" src="app.js"></script>
<script src="wailsjs/runtime/runtime.js"></script>
<script src="wailsjs/go/main/App.js"></script>
<script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -1,508 +1,62 @@
/* Edge AI Platform Installer — Modernized v0.2 */ /* visionA Local — splash screen */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
:root { html, body {
--primary: #6366f1; height: 100%;
--primary-hover: #4f46e5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei', sans-serif;
--primary-light: #e0e7ff; background: #0b1020;
--bg: #fafbff; color: #e6e8f0;
--surface: #ffffff; -webkit-font-smoothing: antialiased;
--border: #e2e8f0;
--text: #1e293b;
--text-secondary: #64748b;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--danger: #dc2626;
--danger-hover: #b91c1c;
--radius: 10px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
} }
#app { #app {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.splash {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh;
}
/* ── Header ─────────────────────────── */
#wizard-header {
display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 24px;
padding: 14px 24px; padding: 48px;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
color: white;
--wails-draggable: drag;
gap: 16px;
} }
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 140px; } .logo {
.logo-text { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; } font-size: 28px;
.version-text { font-size: 10px; opacity: 0.5; }
.header-center { flex: 1; display: flex; justify-content: center; }
.header-right { min-width: 100px; display: flex; justify-content: flex-end; }
.step-indicators { display: flex; align-items: center; gap: 4px; }
.step-dot {
width: 28px; height: 28px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.25);
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700;
color: rgba(255,255,255,0.35);
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.step-dot.active {
border-color: var(--primary);
background: var(--primary);
color: white;
box-shadow: 0 0 12px rgba(99, 102, 241, 0.5);
transform: scale(1.1);
}
.step-dot.completed {
border-color: var(--success);
background: var(--success);
color: white;
}
.step-line {
width: 14px; height: 2px;
background: rgba(255,255,255,0.15);
border-radius: 1px;
transition: background 0.3s;
}
/* ── Language Switcher ─────────────── */
.lang-switch {
display: flex;
align-items: center;
gap: 6px;
--wails-draggable: no-drag;
}
.lang-sep {
color: rgba(255,255,255,0.3);
font-size: 12px;
}
.lang-btn {
background: transparent;
border: 1px solid rgba(255,255,255,0.25);
color: rgba(255,255,255,0.6);
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600; font-weight: 600;
cursor: pointer; letter-spacing: 0.02em;
transition: all 0.2s; color: #ffffff;
}
.lang-btn:hover {
border-color: rgba(255,255,255,0.5);
color: rgba(255,255,255,0.9);
}
.lang-btn.active {
background: rgba(255,255,255,0.15);
border-color: rgba(255,255,255,0.4);
color: white;
}
/* ── Main Content ───────────────────── */
main {
flex: 1;
position: relative;
overflow: hidden;
}
.step {
position: absolute;
inset: 0;
padding: 32px 36px;
display: none;
flex-direction: column;
overflow-y: auto;
opacity: 0;
transform: translateX(20px);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.step.active {
display: flex;
opacity: 1;
transform: translateX(0);
}
h1 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
color: var(--text);
}
.subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 24px;
line-height: 1.6;
}
/* ── Info Card ──────────────────────── */
.info-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 18px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 7px 0;
}
.info-row + .info-row { border-top: 1px solid var(--border); }
.info-label { font-size: 13px; color: var(--text-secondary); }
.info-value { font-size: 13px; font-weight: 500; }
.status-ok { color: var(--success); }
.status-installed { color: var(--success); }
.status-warn { color: var(--warning); }
.status-err { color: var(--error); }
.status-skipped { color: var(--text-secondary); }
/* ── Warning Card ───────────────────── */
.warning-card {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: var(--radius);
padding: 14px 18px;
margin-bottom: 20px;
font-size: 13px;
}
.warning-card p { margin: 6px 0; color: #92400e; }
.existing-path { font-family: "SF Mono", Menlo, monospace; font-size: 12px; }
/* ── Buttons ────────────────────────── */
.actions {
margin-top: auto;
padding-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn {
padding: 9px 22px;
border: none;
border-radius: var(--radius);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--primary);
color: white;
box-shadow: 0 1px 3px rgba(99, 102, 241, 0.3);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--border);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface);
color: var(--text);
border-color: #cbd5e1;
}
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover:not(:disabled) { background: var(--danger-hover); }
.btn-warning { background: var(--warning); color: white; }
.btn-warning:hover:not(:disabled) { background: #d97706; }
/* ── Form Groups ───────────────────── */
.form-group {
margin-bottom: 18px;
}
.field-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.field-hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.5;
font-style: italic;
}
/* ── Path Input ─────────────────────── */
.path-input-group {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.input-field {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
background: var(--surface);
color: var(--text);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
}
.input-field:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
}
.input-field::placeholder {
color: #94a3b8;
}
.status-text {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
min-height: 18px;
}
.status-text.error { color: var(--error); }
/* ── Component List ─────────────────── */
.component-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 20px;
}
.component-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.component-item:hover {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.08);
}
.component-item.required { opacity: 0.85; }
.component-check {
display: flex;
align-items: center;
padding-top: 1px;
}
.component-check input[type="checkbox"] { margin: 0; }
.component-info { display: flex; flex-direction: column; gap: 3px; }
.component-name { font-size: 13px; font-weight: 600; }
.component-desc { font-size: 11px; color: var(--text-secondary); line-height: 1.4; }
/* ── Progress ───────────────────────── */
.progress-container {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.progress-bar {
flex: 1;
height: 10px;
background: var(--border);
border-radius: 5px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), #818cf8, var(--primary));
background-size: 200% 100%;
border-radius: 5px;
transition: width 0.4s ease;
animation: progress-pulse 2s ease-in-out infinite;
}
@keyframes progress-pulse {
0%, 100% { background-position: 0% 0%; }
50% { background-position: 100% 0%; }
}
.progress-percent {
font-size: 14px;
font-weight: 700;
color: var(--primary);
min-width: 40px;
text-align: right;
}
.progress-message {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 14px;
}
.log-area {
flex: 1;
min-height: 120px;
max-height: 220px;
background: #0f172a;
color: #94a3b8;
border-radius: var(--radius);
padding: 12px 14px;
font-family: "SF Mono", "Menlo", "Cascadia Code", monospace;
font-size: 11px;
line-height: 1.7;
overflow-y: auto;
box-shadow: inset 0 1px 4px rgba(0,0,0,0.2);
}
.log-area .log-line { color: #94a3b8; }
.log-area .log-error { color: var(--error); }
.log-area .log-success { color: var(--success); }
/* ── Hardware List ──────────────────── */
.hardware-list { margin-bottom: 20px; }
.device-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: border-color 0.2s;
}
.device-card:hover { border-color: var(--primary); }
.device-card .device-icon {
width: 40px; height: 40px;
background: var(--primary-light);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
color: var(--primary);
}
.device-card .device-info { display: flex; flex-direction: column; gap: 3px; }
.device-card .device-name { font-size: 14px; font-weight: 600; }
.device-card .device-detail { font-size: 11px; color: var(--text-secondary); }
.device-scanning {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px;
color: var(--text-secondary);
font-size: 13px;
} }
.spinner { .spinner {
width: 28px; height: 28px; width: 42px;
border: 3px solid var(--border); height: 42px;
border-top-color: var(--primary); border: 3px solid rgba(255, 255, 255, 0.12);
border-top-color: #6ea8ff;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.9s linear infinite;
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
.no-devices { .status {
text-align: center; font-size: 14px;
padding: 28px; color: #a7b0c6;
color: var(--text-secondary); }
.error {
max-width: 560px;
padding: 16px 20px;
background: rgba(220, 38, 38, 0.12);
border: 1px solid rgba(220, 38, 38, 0.4);
border-radius: 8px;
color: #fca5a5;
font-size: 13px; font-size: 13px;
} line-height: 1.5;
/* ── Complete Icon ──────────────────── */
.complete-icon {
text-align: center; text-align: center;
margin-bottom: 12px;
color: var(--success);
animation: checkmark-pop 0.5s ease;
}
@keyframes checkmark-pop {
0% { transform: scale(0.5); opacity: 0; }
60% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
/* ── Summary List ──────────────────── */
.summary-list {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 18px;
margin-bottom: 20px;
}
/* ── Scrollbar ──────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ── Step transition for newly shown steps ── */
@keyframes step-enter {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.step.active {
animation: step-enter 0.35s ease forwards;
} }