// Import configuration schema
const CONFIG_FIELDS = window.CONFIG_SCHEMA;
// Process state tracking
let isProcessRunning = false;
let buttonManager;
let loadingOverlay;
// Auto-save functionality with debouncing
let saveTimeout;
const SAVE_DELAY = 500;
let isSettingValues = false;
// Console management
const consoleContent = document.getElementById('console-content');
const MAX_CONSOLE_LINES = 512;
let consoleLineCount = 0;
// Button management system
class ButtonManager {
constructor() {
this.buttons = {
start: document.getElementById('start-process'),
stop: document.getElementById('stop-process'),
setupVenv: document.getElementById('setup-venv'),
resetVenv: document.getElementById('reset-venv'),
refreshMicrophones: document.getElementById('refresh-microphones')
};
// Initialize button states - process is not running at startup
this.setProcessStopped();
}
setState(buttonName, disabled) {
const button = this.buttons[buttonName];
if (!button) return;
button.disabled = disabled;
}
setProcessRunning() {
this.setState('start', true);
this.setState('stop', false);
isProcessRunning = true;
}
setProcessStopped() {
this.setState('start', false);
this.setState('stop', true);
isProcessRunning = false;
}
async withButtonLoading(buttonName, asyncFn) {
this.setState(buttonName, true);
try {
return await asyncFn();
} finally {
this.setState(buttonName, false);
}
}
}
// Add loading overlay management
class LoadingOverlay {
constructor() {
this.overlay = document.getElementById('loading-overlay');
this.form = document.getElementById('config-form');
this.messageElement = this.overlay.querySelector('p');
this.defaultMessage = 'Environment setup underway - please wait.';
this.originalStates = new Map(); // Track original disabled states
}
show(message = null) {
this.messageElement.textContent = message || this.defaultMessage;
this.overlay.classList.remove('hidden');
// Disable all form inputs and buttons in the entire left panel
const leftPanel = this.overlay.parentElement;
const inputs = leftPanel.querySelectorAll('input, select, textarea, button');
inputs.forEach(input => {
// Store original disabled state before disabling
this.originalStates.set(input, input.disabled);
input.disabled = true;
input.classList.add('opacity-50');
});
}
hide() {
this.overlay.classList.add('hidden');
// Restore original states of form inputs and buttons
const leftPanel = this.overlay.parentElement;
const inputs = leftPanel.querySelectorAll('input, select, textarea, button');
inputs.forEach(input => {
// Restore original disabled state
input.disabled = this.originalStates.get(input) || false;
input.classList.remove('opacity-50');
});
// Clear the stored states
this.originalStates.clear();
// Reset to default message
this.messageElement.textContent = this.defaultMessage;
}
}
// Handle status messages with better color management
function showStatus(message, type = 'info') {
const statusEl = document.getElementById('status-message');
statusEl.textContent = message;
// Remove all status classes
const statusClasses = ['hidden', 'bg-green-100', 'bg-red-100', 'bg-blue-100', 'text-green-800', 'text-red-800', 'text-blue-800'];
statusEl.classList.remove(...statusClasses);
// Add appropriate classes based on type
const typeMap = {
success: ['bg-green-100', 'text-green-800'],
error: ['bg-red-100', 'text-red-800'],
info: ['bg-blue-100', 'text-blue-800']
};
statusEl.classList.add(...(typeMap[type] || typeMap.info));
// Also log to console
appendToConsole(message, type === 'error' ? 'stderr' : 'info');
setTimeout(() => statusEl.classList.add('hidden'), 5000);
}
// Get form values using field mappings
function getFormValues() {
const config = {};
for (const [fieldName, fieldConfig] of Object.entries(CONFIG_FIELDS)) {
const element = document.getElementById(fieldName);
if (!element) continue;
switch (fieldConfig.type) {
case 'boolean':
config[fieldName] = element.checked ? 1 : 0;
break;
case 'number':
const numValue = parseInt(element.value);
config[fieldName] = isNaN(numValue) ? fieldConfig.default : numValue;
break;
case 'text':
config[fieldName] = element.value || fieldConfig.default;
break;
default:
config[fieldName] = element.value || fieldConfig.default;
}
}
return config;
}
// Set form values using field mappings
function setFormValues(config) {
isSettingValues = true; // Disable auto-save temporarily
for (const [fieldName, fieldConfig] of Object.entries(CONFIG_FIELDS)) {
const element = document.getElementById(fieldName);
if (!element) continue;
const value = config[fieldName] ?? fieldConfig.default;
switch (fieldConfig.type) {
case 'boolean':
element.checked = value === 1;
break;
case 'text':
element.value = value || '';
break;
default:
element.value = value;
}
}
// Handle use_builtin toggle state
const useBuiltin = config.use_builtin === 1;
const customChatboxInputs = ['block_width', 'num_blocks', 'rows', 'cols'];
customChatboxInputs.forEach(inputId => {
const input = document.getElementById(inputId);
if (input) {
input.disabled = useBuiltin;
if (useBuiltin) {
input.classList.add('opacity-50', 'cursor-not-allowed');
} else {
input.classList.remove('opacity-50', 'cursor-not-allowed');
}
}
});
// Update volume display
if (config.volume !== undefined) {
const volumePercent = Math.round(config.volume);
document.getElementById('volume-display').textContent = `${volumePercent}%`;
}
isSettingValues = false; // Re-enable auto-save
}
function appendToConsole(message, type = 'stdout') {
const timestamp = new Date().toLocaleTimeString();
const timestampSpan = document.createElement('span');
timestampSpan.className = 'console-timestamp';
timestampSpan.textContent = `[${timestamp}] `;
const messageSpan = document.createElement('span');
messageSpan.className = `console-${type}`;
// Redact usernames from paths.
const redactedMessage = message.replace(/([A-Za-z]:[\/\\]Users[\/\\])([^\/\\]+)/g, '$1*****');
messageSpan.textContent = redactedMessage;
const lineDiv = document.createElement('div');
lineDiv.appendChild(timestampSpan);
lineDiv.appendChild(messageSpan);
consoleContent.appendChild(lineDiv);
consoleLineCount++;
// Remove old lines if we exceed the limit
if (consoleLineCount > MAX_CONSOLE_LINES) {
// Calculate how many lines to remove (remove 10% to avoid frequent trimming)
const linesToRemove = Math.floor(MAX_CONSOLE_LINES * 0.1);
// Remove the oldest lines
for (let i = 0; i < linesToRemove; i++) {
if (consoleContent.firstChild) {
consoleContent.removeChild(consoleContent.firstChild);
}
}
consoleLineCount -= linesToRemove;
// Add a notice that lines were trimmed
const trimNotice = document.createElement('div');
trimNotice.className = 'console-info';
trimNotice.innerHTML = '[System] ... older lines removed to maintain performance ...';
consoleContent.insertBefore(trimNotice, consoleContent.firstChild);
}
// Auto-scroll to bottom
const pythonConsole = document.getElementById('python-console');
pythonConsole.scrollTop = pythonConsole.scrollHeight;
}
// Async action handler with better error handling
async function handleAsyncAction(actionName, actionFn) {
try {
const result = await actionFn();
if (result?.message) {
showStatus(result.message, 'success');
}
return result;
} catch (error) {
showStatus(`${actionName} failed: ${error.message}`, 'error');
throw error;
}
}
async function autoSaveConfig() {
if (isSettingValues) return;
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
try {
const config = getFormValues();
await window.electronAPI.saveConfig(config);
showStatus('Configuration saved', 'success');
// Restart process if running
if (isProcessRunning) {
appendToConsole('Restarting process with new configuration...', 'info');
try {
await window.electronAPI.stopProcess();
await new Promise(resolve => setTimeout(resolve, 1000));
await window.electronAPI.startProcess();
buttonManager.setProcessRunning();
appendToConsole('Process restarted with new configuration', 'info');
} catch (error) {
appendToConsole(`Failed to restart process: ${error.message}`, 'stderr');
buttonManager.setProcessStopped();
}
}
} catch (error) {
showStatus(`Failed to save configuration: ${error.message}`, 'error');
}
}, SAVE_DELAY);
}
// Auto-save setup
function setupAutoSave() {
const form = document.getElementById('config-form');
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const eventType = input.type === 'checkbox' ? 'change' :
(input.type === 'number' || input.type === 'text' || input.tagName === 'TEXTAREA') ? 'input' : 'change';
input.addEventListener(eventType, autoSaveConfig);
});
}
// Microphone loading
async function loadMicrophones() {
const microphoneSelect = document.getElementById('microphone');
try {
// Check/install requirements during startup
appendToConsole('Checking virtual environment and requirements...', 'info');
loadingOverlay.show('Setting up environment - this can take several minutes.');
try {
await handleAsyncAction('Install requirements', () => window.electronAPI.installRequirements());
} finally {
loadingOverlay.hide(); // Always hide overlay when done
}
appendToConsole('Loading available microphones...', 'info');
const microphones = await window.electronAPI.getMicrophones();
microphoneSelect.innerHTML = '';
if (microphones.length === 0) {
microphoneSelect.innerHTML = '';
appendToConsole('No microphones found', 'stderr');
return;
}
appendToConsole(`Found ${microphones.length} microphone(s)`, 'info');
microphones.forEach(mic => {
const option = document.createElement('option');
option.value = mic.index.toString();
option.textContent = mic.name;
microphoneSelect.appendChild(option);
appendToConsole(` - ${mic.name} (Device ${mic.index})`, 'stdout');
});
// Restore previously selected microphone
try {
const config = await window.electronAPI.loadConfig();
if (config.microphone) {
microphoneSelect.value = config.microphone;
}
} catch (error) {
// Ignore config load errors here
}
} catch (error) {
appendToConsole(`Failed to load microphones: ${error.message}`, 'stderr');
microphoneSelect.innerHTML = '';
}
}
// Event handlers setup
function setupEventHandlers() {
// Advanced settings toggle
document.getElementById('toggle-advanced').addEventListener('click', () => {
const advancedSettings = document.getElementById('advanced-settings');
const chevron = document.getElementById('chevron');
if (advancedSettings.classList.contains('hidden')) {
advancedSettings.classList.remove('hidden');
chevron.classList.add('rotate-90');
} else {
advancedSettings.classList.add('hidden');
chevron.classList.remove('rotate-90');
}
});
// Use builtin chatbox toggle
document.getElementById('use_builtin').addEventListener('change', (e) => {
const customChatboxInputs = ['block_width', 'num_blocks', 'rows', 'cols'];
const isBuiltin = e.target.checked;
customChatboxInputs.forEach(inputId => {
const input = document.getElementById(inputId);
if (input) {
input.disabled = isBuiltin;
if (isBuiltin) {
input.classList.add('opacity-50', 'cursor-not-allowed');
} else {
input.classList.remove('opacity-50', 'cursor-not-allowed');
}
}
});
});
// Volume slider update
document.getElementById('volume').addEventListener('input', (e) => {
const volumePercent = Math.round(e.target.value);
document.getElementById('volume-display').textContent = `${volumePercent}%`;
});
// Setup virtual environment
document.getElementById('setup-venv').addEventListener('click', async () => {
loadingOverlay.show('Setting up virtual environment - please wait...'); // Show overlay with custom message
try {
await buttonManager.withButtonLoading('setupVenv', async () => {
await window.electronAPI.deleteVenvIndicatorFile();
await handleAsyncAction('Install requirements', () => window.electronAPI.installRequirements());
});
} finally {
loadingOverlay.hide(); // Always hide overlay when done
}
});
// Reset virtual environment
document.getElementById('reset-venv').addEventListener('click', async () => {
loadingOverlay.show('Resetting virtual environment - please wait...'); // Show overlay with custom message
try {
await buttonManager.withButtonLoading('resetVenv', async () => {
await handleAsyncAction('Reset virtual environment', () => window.electronAPI.resetVenv());
});
} finally {
loadingOverlay.hide(); // Always hide overlay when done
}
});
// Reset configuration
document.getElementById('reset-config').addEventListener('click', async () => {
const confirmReset = confirm('Are you sure you want to reset all settings to defaults? This cannot be undone.');
if (!confirmReset) return;
try {
// Stop process if running
const wasRunning = isProcessRunning;
if (wasRunning) {
appendToConsole('Stopping process before resetting configuration...', 'info');
await window.electronAPI.stopProcess();
buttonManager.setProcessStopped();
await new Promise(resolve => setTimeout(resolve, 500));
}
// Reset configuration
appendToConsole('Resetting configuration to defaults...', 'info');
const result = await window.electronAPI.resetConfig();
// Reload configuration with defaults
const config = await window.electronAPI.loadConfig();
setFormValues(config);
showStatus(result.message, 'success');
appendToConsole('Configuration reset successfully', 'info');
// Restart process if it was running
if (wasRunning) {
appendToConsole('Restarting process with default configuration...', 'info');
await window.electronAPI.startProcess();
buttonManager.setProcessRunning();
appendToConsole('Process restarted with default configuration', 'info');
}
} catch (error) {
showStatus(`Failed to reset configuration: ${error.message}`, 'error');
appendToConsole(`Failed to reset configuration: ${error.message}`, 'stderr');
}
});
// Refresh microphones
document.getElementById('refresh-microphones').addEventListener('click', async () => {
await buttonManager.withButtonLoading('refreshMicrophones', async () => {
await loadMicrophones();
});
});
// Start process
document.getElementById('start-process').addEventListener('click', async () => {
buttonManager.setState('start', true);
try {
// The installRequirements function will now check if venv is set up.
loadingOverlay.show('Verifying environment setup - please wait...'); // Show overlay with custom message
try {
await window.electronAPI.installRequirements();
appendToConsole('Virtual environment setup checked/completed', 'info');
} finally {
loadingOverlay.hide(); // Always hide overlay when done
}
await window.electronAPI.startProcess();
buttonManager.setProcessRunning();
appendToConsole('Process started successfully', 'info');
} catch (error) {
appendToConsole(`Failed to start process: ${error.message}`, 'stderr');
buttonManager.setState('start', false);
}
});
// Stop process
document.getElementById('stop-process').addEventListener('click', async () => {
buttonManager.setState('stop', true);
try {
await window.electronAPI.stopProcess();
appendToConsole('Process stop initiated', 'info');
} catch (error) {
appendToConsole(`Failed to stop process: ${error.message}`, 'stderr');
buttonManager.setState('stop', false);
}
});
// Listen for process stopped event
window.electronAPI.onProcessStopped(() => {
buttonManager.setProcessStopped();
});
}
// Initialize application
window.addEventListener('load', async () => {
appendToConsole('TaSTT Configuration UI initialized', 'info');
loadingOverlay = new LoadingOverlay();
buttonManager = new ButtonManager();
// Set up Python output listener first so we capture all output
window.electronAPI.onPythonOutput((data) => {
appendToConsole(data.message, data.type);
});
// Load configuration
try {
const config = await window.electronAPI.loadConfig();
setFormValues(config);
appendToConsole('Configuration loaded', 'info');
} catch (error) {
appendToConsole(`Failed to load configuration: ${error.message}`, 'stderr');
}
// Load microphones
await loadMicrophones();
// Setup event handlers and auto-save
setupEventHandlers();
setupAutoSave();
});