jim800121chen c54f16fca0 Initial commit: visionA monorepo with local-tool subproject
local-tool/: visionA-local desktop app
- M1: Wails shell + Go server + Next.js UI + Mock mode (macOS dmg ready)
- M2: i18n (zh-TW/en) + Settings 4-tab refactor
- M3: Embedded Python 3.12 runtime (python-build-standalone) + KneronPLUS wheels
- M4: Windows Inno Setup script (build on Windows runner)
- M5: Linux AppImage script + udev rule (build on Linux runner)
- M6: ffmpeg (GPL, pending legal review) + yt-dlp bundled
- Lifecycle: watchServer health check, fatal native dialog,
            Wails IPC raise endpoint, stale process cleanup

.autoflow/: full PRD / Design Spec / Architecture / Testing docs
            (4 rounds tri-party discussion + cross review)
.github/workflows/: macOS / Windows / Linux build CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:10:38 +08:00

541 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Edge AI Platform Installer — Wizard Controller v0.2
// ── i18n Dictionary ───────────────────────────────────────
const i18n = {
en: {
'welcome.title': 'Edge AI Platform Installer',
'welcome.subtitle': 'Set up your edge AI development environment with Kneron hardware support.',
'path.title': 'Installation Path',
'path.subtitle': 'Choose where to install Edge AI Platform.',
'path.browse': 'Browse',
'path.required': 'Installation path is required.',
'path.valid': 'Path is valid.',
'components.title': 'Select Components',
'components.subtitle': 'Choose which components to install.',
'components.server': 'Edge AI Server',
'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 {
await window.go.main.Installer.StartInstall(installConfig);
} catch (err) {
addLogLine('Error: ' + err, 'error');
}
}
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;
}
showStep(2);
});
// Step 2 Back -> Step 1
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
document.getElementById('btn-back-3').addEventListener('click', () => {
showStep(2);
});
// 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);
}
});
// Step 3 Next -> collect relay fields -> Step 4 (Progress) -> start install
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)
document.getElementById('btn-next-5').addEventListener('click', () => {
showStep(6);
document.getElementById('summary-path').textContent = installConfig.installDir;
const modelsEl = document.getElementById('summary-models');
modelsEl.textContent = t('complete.installed');
modelsEl.className = 'info-value status-ok';
const pyEl = document.getElementById('summary-python');
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';
}
});
});