Files
wisp-open/frontend/index.html
T
jahruz67 5fcef76424 Enhance Linux Wayland support and refine transcription logic
- Added instructions for Linux Wayland paste setup in README.md.
- Removed unnecessary JavaScript file and updated references in HTML files.
- Updated DefaultAIPrompt to clarify the role of the transcription tool.
- Removed overlay notifications for Linux to reduce desktop clutter.
- Implemented checks to ensure transcript integrity during refinement.
- Added tests to validate transcript preservation logic.
- Removed ydotool setup script as it is no longer needed.
2026-05-20 17:08:01 -07:00

632 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Wisp Settings</title>
<link rel="stylesheet" href="./src/style.css">
</head>
<body>
<div id="app" class="container">
<!-- Header -->
<div class="flex-between" style="margin-bottom: 28px;">
<h1>Settings</h1>
<div id="appVersion" style="color: var(--text-2); font-size: 12px; font-weight: 500;"></div>
</div>
<div id="saveStatus" class="save-status">Saved</div>
<!-- Settings Form -->
<div id="settingsForm">
<div class="section-group-label">Authentication</div>
<!-- API Key -->
<div class="section">
<label>Groq API Key</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
<input type="password" id="apiKey" placeholder="gsk_...">
<button class="eye-btn" onclick="toggleApiKey()" id="eyeBtn">👁️</button>
</div>
<button onclick="saveApiKey()">Save</button>
</div>
</div>
<p class="hint">Get your free key at <a href="https://console.groq.com/keys" target="_blank" style="color: var(--accent);">console.groq.com/keys</a></p>
</div>
<div class="section-group-label">Input</div>
<!-- Linux Wayland Hotkey Command -->
<div class="section" id="linuxPressSection" style="display: none;">
<label>System Hotkey Command (Linux)</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
<input type="text" id="linuxPressCommand" readonly>
</div>
<button onclick="copyLinuxPressCommand()">Copy</button>
</div>
</div>
<p class="hint">GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.</p>
<p class="hint">KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.</p>
<p class="hint">After running the ydotool setup commands, restart your computer, then open WIS Free V3 again.</p>
<div id="linuxYdotoolStatus" style="display: none; margin-top: 12px; padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.45;"></div>
</div>
<!-- Microphone -->
<div class="section">
<label>Microphone</label>
<select id="micDevice" onchange="saveMicDevice()" class="form-control">
<option value="default">System Default</option>
</select>
</div>
<div class="section-group-label">Transcription</div>
<!-- Whisper Model -->
<div class="section">
<label>Whisper Model</label>
<select id="whisperModel" onchange="saveWhisperModel()" class="form-control">
<option value="whisper-large-v3-turbo">whisper-large-v3-turbo (Recommended)</option>
<option value="whisper-large-v3">whisper-large-v3</option>
</select>
</div>
<!-- Language -->
<div class="section">
<label>Language</label>
<select id="language" onchange="saveLanguage()" class="form-control">
<option value="auto">Auto-Detect</option>
<option value="en">English (en)</option>
<option value="es">Spanish (es)</option>
</select>
<p class="hint">Specifying language reduces latency and improves accuracy.</p>
</div>
<div class="section-group-label">AI Refinement</div>
<!-- AI Model -->
<div class="section">
<label>AI Model</label>
<select id="aiModel" onchange="saveAiModel()" class="form-control">
<option value="None">None — skip refinement</option>
<option value="openai/gpt-oss-120b-high">openai/gpt-oss-120b (high reasoning)</option>
<option value="openai/gpt-oss-120b">openai/gpt-oss-120b (low reasoning)</option>
<option value="openai/gpt-oss-20b-high">openai/gpt-oss-20b (high reasoning)</option>
<option value="openai/gpt-oss-20b">openai/gpt-oss-20b (low reasoning)</option>
<option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option>
<option value="llama-3.1-8b-instant">llama-3.1-8b-instant (faster)</option>
</select>
</div>
<!-- AI Prompt -->
<div class="section">
<label>System Prompt</label>
<textarea id="aiPrompt" rows="3" placeholder="Custom instructions for text refinement..." class="form-control"></textarea>
<div style="text-align: right; margin-top: 10px;">
<button onclick="savePrompt()">Save Prompt</button>
</div>
</div>
<div class="section-group-label">System</div>
<!-- Startup -->
<div class="section flex-between">
<div>
<span id="startupLabel" style="font-weight: 500;">Run on System Startup</span>
<p class="hint" style="margin-top: 4px; margin-bottom: 0;">Launch automatically when you log in.</p>
</div>
<label class="flex-row" style="margin: 0; cursor: pointer;">
<input type="checkbox" id="startupToggle" onchange="toggleStartup()" style="width: auto;">
</label>
</div>
<!-- History -->
<div class="section">
<div class="flex-between" style="margin-bottom: 12px;">
<label style="margin: 0;">Transcription History</label>
<button onclick="clearHistory()" class="btn-link">Clear all</button>
</div>
<div id="historyList" class="history-list">
<div style="text-align: center; color: var(--text-2); padding: 24px 0; font-size: 13px;">No history yet.</div>
</div>
</div>
<!-- Offline Whisper -->
<div id="offlineWhisperSection" class="section" style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border);">
<label>Offline Whisper</label>
<!-- Connection Status -->
<div id="connectionStatus" style="margin-bottom: 14px; padding: 9px 14px; border-radius: 7px; text-align: center; font-size: 13px;">
Checking connection...
</div>
<!-- Whisper Status -->
<div id="whisperSection">
<div id="whisperNotInstalled">
<p class="hint" style="margin-bottom: 14px;">
Install offline transcription for use without internet. Uses GPU acceleration if available.
</p>
<div style="margin-bottom: 14px;">
<label style="font-size: 12px; color: var(--text-2); margin-bottom: 6px;">Model Size</label>
<select id="whisperModelSelect" class="form-control">
<option value="tiny">Tiny (75 MB) — Fastest</option>
<option value="base">Base (150 MB) — Fast</option>
<option value="small" selected>Small (500 MB) — Recommended</option>
<option value="medium">Medium (1.5 GB) — Best Quality</option>
</select>
</div>
<button onclick="installWhisper()" id="installBtn">
Install Offline Whisper
</button>
</div>
<div id="whisperInstalled" style="display: none;">
<div style="background: var(--green-dim); border: 1px solid rgba(61, 186, 110, 0.25); border-radius: 7px; padding: 13px 16px; margin-bottom: 14px;">
<div style="color: var(--green); font-weight: 500; font-size: 13px;">✓ Offline Whisper installed</div>
<div style="font-size: 12px; color: var(--text-2); margin-top: 4px;">
Model: <span id="installedModel"></span>
</div>
</div>
<p class="hint" style="margin-bottom: 14px;">
Select "Local — Whisper" in the Whisper Model dropdown above to use offline transcription.
</p>
<button onclick="uninstallWhisper()" class="btn-danger">
Uninstall Offline Whisper
</button>
</div>
</div>
</div>
</div>
</div>
<script type="module">
let apiKeyVisible = false;
// Toggle API key visibility
window.toggleApiKey = function () {
const input = document.getElementById('apiKey');
const btn = document.getElementById('eyeBtn');
apiKeyVisible = !apiKeyVisible;
input.type = apiKeyVisible ? 'text' : 'password';
btn.textContent = apiKeyVisible ? '🙈' : '👁️';
};
// Record shortcut
window.recordShortcut = function () {
const btn = document.getElementById('recordBtn');
const input = document.getElementById('shortcutInput');
// WebView2 on Windows often omits metaKey on key events; e.code still identifies Meta.
const winKeyActive = (e) =>
e.metaKey ||
e.key === 'Meta' ||
e.key === 'OS' ||
e.code === 'MetaLeft' ||
e.code === 'MetaRight';
btn.textContent = 'Recording...';
btn.disabled = true;
input.value = '';
let lastShortcut = '';
const keydownHandler = (e) => {
e.preventDefault();
e.stopPropagation();
const mods = [];
if (e.ctrlKey) mods.push('ctrl');
if (e.altKey) mods.push('alt');
if (e.shiftKey) mods.push('shift');
if (winKeyActive(e)) mods.push('win');
let key = e.key.toLowerCase();
if (key === 'control') key = 'ctrl';
if (key === 'alt') key = 'alt';
if (key === 'shift') key = 'shift';
if (key === 'meta' || key === 'os') key = 'win';
if (e.code === 'MetaLeft' || e.code === 'MetaRight') key = 'win';
if (key === ' ') key = 'space';
const parts = new Set(mods);
parts.add(key);
// Order them: modifiers first, then the latest key
const partsArray = Array.from(parts).filter(p => p !== key);
partsArray.push(key);
lastShortcut = partsArray.join('+');
input.value = lastShortcut;
// Finalize immediately if a non-modifier key is pressed
if (!['ctrl', 'alt', 'shift', 'win'].includes(key)) {
finish();
}
};
const keyupHandler = (e) => {
e.preventDefault();
e.stopPropagation();
// Finalize when keys are released if we have a valid shortcut
if (lastShortcut) {
// Check if it's a modifier-only shortcut that we should finalize
const isModifierOnly = lastShortcut.split('+').every(p => ['ctrl', 'alt', 'shift', 'win'].includes(p));
if (isModifierOnly && lastShortcut.includes('+')) {
// Wait a tiny bit to see if they press another key, but usually release means they are done
setTimeout(finish, 100);
} else if (!e.ctrlKey && !e.altKey && !e.shiftKey && !winKeyActive(e)) {
finish();
}
}
};
const finish = () => {
if (!lastShortcut) {
btn.textContent = 'Record';
btn.disabled = false;
cleanup();
return;
}
window.go.main.App.SaveSettings({ shortcut: lastShortcut }).then((result) => {
if (result && result.indexOf('Invalid shortcut') !== -1) {
alert(result);
}
btn.textContent = 'Record';
btn.disabled = false;
cleanup();
});
};
const cleanup = () => {
window.removeEventListener('keydown', keydownHandler, true);
window.removeEventListener('keyup', keyupHandler, true);
};
window.addEventListener('keydown', keydownHandler, true);
window.addEventListener('keyup', keyupHandler, true);
};
function renderHistoryList(history) {
const container = document.getElementById('historyList');
if (!history || history.length === 0) {
container.innerHTML = '<div style="text-align: center; color: var(--text-2); padding: 24px 0; font-size: 13px;">No history yet.</div>';
return;
}
container.innerHTML = '';
[...history].reverse().forEach(item => {
const el = document.createElement('div');
el.className = 'history-item';
const time = document.createElement('span');
time.className = 'history-time';
time.textContent = new Date(item.timestamp).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
const text = document.createElement('div');
text.textContent = item.text;
el.appendChild(time);
el.appendChild(text);
container.appendChild(el);
});
}
function renderLinuxYdotoolStatus(status) {
const el = document.getElementById('linuxYdotoolStatus');
if (!el || !status) return;
const ready = !!status.ready;
el.style.display = 'block';
el.style.background = ready ? 'var(--green-dim)' : 'var(--red-dim)';
el.style.color = ready ? 'var(--green)' : 'var(--red)';
el.style.border = ready ? '1px solid rgba(61, 186, 110, 0.25)' : '1px solid rgba(224, 82, 82, 0.25)';
el.innerHTML = '';
const title = document.createElement('div');
title.style.fontWeight = '600';
title.textContent = ready ? 'Automatic paste ready' : 'Automatic paste needs ydotool setup';
el.appendChild(title);
const message = document.createElement('div');
message.style.marginTop = '4px';
message.textContent = status.message || '';
el.appendChild(message);
if (!ready && Array.isArray(status.setup_commands) && status.setup_commands.length) {
const pre = document.createElement('pre');
pre.style.whiteSpace = 'pre-wrap';
pre.style.margin = '8px 0 0';
pre.style.color = 'inherit';
pre.textContent = status.setup_commands.join('\n');
el.appendChild(pre);
const restart = document.createElement('div');
restart.style.marginTop = '8px';
restart.style.fontWeight = '600';
restart.textContent = 'Restart your computer after setup, then open WIS Free V3 again.';
el.appendChild(restart);
}
}
async function refreshHistoryFromBackend() {
try {
const settings = await window.go.main.App.GetSettings();
renderHistoryList(settings.history || []);
} catch (err) {
console.error('Failed to refresh history:', err);
}
}
// Load settings on page load
async function loadSettings() {
try {
const settings = await window.go.main.App.GetSettings();
document.getElementById('apiKey').value = settings.api_key || '';
const shortcutInput = document.getElementById('shortcutInput');
if (shortcutInput) {
shortcutInput.value = settings.shortcut || 'alt+z';
}
if (settings.linux_press_mode) {
const section = document.getElementById('linuxPressSection');
const cmdInput = document.getElementById('linuxPressCommand');
if (section) section.style.display = 'block';
if (cmdInput) cmdInput.value = settings.linux_press_command || '';
renderLinuxYdotoolStatus(settings.linux_ydotool_status);
}
document.getElementById('whisperModel').value = settings.whisper_model || 'whisper-large-v3-turbo';
document.getElementById('aiModel').value = settings.ai_model || 'llama-3.3-70b-versatile';
document.getElementById('aiPrompt').value = settings.ai_prompt || '';
document.getElementById('language').value = settings.language || 'en';
document.getElementById('startupToggle').checked = settings.startup || false;
const ver = (settings.app_version && String(settings.app_version).trim()) || 'dev';
document.getElementById('appVersion').textContent = ver === 'dev' ? 'dev' : ('v' + ver);
renderHistoryList(settings.history || []);
} catch (err) {
console.error("Failed to load settings:", err);
}
}
// Load mics
async function loadMics() {
try {
const mics = await window.go.main.App.GetMicrophones();
const select = document.getElementById('micDevice');
mics.forEach(mic => {
if (mic.index !== -1) {
const opt = document.createElement('option');
opt.value = mic.index;
opt.textContent = mic.name;
select.appendChild(opt);
}
});
} catch (err) {
console.error("Failed to load mics:", err);
}
}
// Save functions
window.saveApiKey = async function () {
const key = document.getElementById('apiKey').value.trim();
await window.go.main.App.SaveSettings({ api_key: key });
showSaveStatus();
};
function showSaveStatus(message = 'Saved') {
const el = document.getElementById('saveStatus');
el.textContent = message;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2000);
}
window.saveWhisperModel = async function () {
await window.go.main.App.SaveSettings({ whisper_model: document.getElementById('whisperModel').value });
showSaveStatus();
};
window.saveAiModel = async function () {
await window.go.main.App.SaveSettings({ ai_model: document.getElementById('aiModel').value });
showSaveStatus();
};
window.saveLanguage = async function () {
await window.go.main.App.SaveSettings({ language: document.getElementById('language').value });
showSaveStatus();
};
window.saveMicDevice = async function () {
const val = document.getElementById('micDevice').value;
await window.go.main.App.SaveSettings({ microphone_device: val === 'default' ? null : parseInt(val) });
showSaveStatus();
};
window.savePrompt = async function () {
await window.go.main.App.SaveSettings({ ai_prompt: document.getElementById('aiPrompt').value.trim() });
showSaveStatus('Prompt saved');
};
window.copyLinuxPressCommand = async function () {
const cmdInput = document.getElementById('linuxPressCommand');
if (!cmdInput) return;
const command = cmdInput.value || '';
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(command);
} else {
cmdInput.focus();
cmdInput.select();
document.execCommand('copy');
}
showSaveStatus('Command copied');
} catch (err) {
console.error('Failed to copy Linux command:', err);
}
};
window.toggleStartup = async function () {
await window.go.main.App.ToggleStartup(document.getElementById('startupToggle').checked);
};
window.clearHistory = async function () {
if (confirm('Clear all transcription history?')) {
await window.go.main.App.ClearHistory();
renderHistoryList([]);
}
};
// Check connection status
async function checkConnection() {
try {
const isOnline = await window.go.main.App.CheckOnline();
const statusEl = document.getElementById('connectionStatus');
if (isOnline) {
statusEl.innerHTML = '● Online';
statusEl.style.background = 'var(--green-dim)';
statusEl.style.color = 'var(--green)';
statusEl.style.border = '1px solid rgba(61, 186, 110, 0.2)';
} else {
statusEl.innerHTML = '● Offline — install local Whisper for transcription';
statusEl.style.background = 'var(--red-dim)';
statusEl.style.color = 'var(--red)';
statusEl.style.border = '1px solid rgba(224, 82, 82, 0.2)';
// Disable cloud AI options when offline
const aiSelect = document.getElementById('aiModel');
for (let opt of aiSelect.options) {
if (opt.value !== 'None' && !opt.value.startsWith('local-')) {
opt.disabled = true;
if (!opt.text.includes('(offline)')) {
opt.text += ' (offline)';
}
}
}
}
} catch (err) {
console.error('Connection check failed:', err);
}
}
// Check whisper installation status
async function checkWhisperStatus() {
try {
const info = await window.go.main.App.GetWhisperInfo();
const whisperSelect = document.getElementById('whisperModel');
// Remove any existing local option
const existingLocal = whisperSelect.querySelector('option[value^="local-"]');
if (existingLocal) existingLocal.remove();
if (info.installed) {
document.getElementById('whisperNotInstalled').style.display = 'none';
document.getElementById('whisperInstalled').style.display = 'block';
document.getElementById('installedModel').textContent = info.model + ' model';
// Add local option to whisper dropdown
const localOption = document.createElement('option');
localOption.value = 'local-' + info.model;
localOption.textContent = '⬡ Local — ' + info.model + ' (offline)';
localOption.style.fontWeight = 'bold';
whisperSelect.insertBefore(localOption, whisperSelect.firstChild);
} else {
document.getElementById('whisperNotInstalled').style.display = 'block';
document.getElementById('whisperInstalled').style.display = 'none';
}
} catch (err) {
console.error('Whisper status check failed:', err);
}
}
// Install whisper
window.installWhisper = async function () {
const model = document.getElementById('whisperModelSelect').value;
const btn = document.getElementById('installBtn');
btn.disabled = true;
btn.textContent = 'Starting installation...';
try {
const result = await window.go.main.App.InstallWhisper(model);
alert(result + '\n\nA terminal window will open showing the installation progress. This may take several minutes depending on your internet speed.');
// Check status after a delay
setTimeout(checkWhisperStatus, 5000);
} catch (err) {
alert('Installation failed: ' + err);
}
btn.disabled = false;
btn.textContent = 'Install Offline Whisper';
};
// Uninstall whisper
window.uninstallWhisper = async function () {
if (!confirm('Uninstall offline Whisper? This will delete the downloaded model.')) {
return;
}
try {
const result = await window.go.main.App.UninstallWhisper();
alert(result);
checkWhisperStatus();
} catch (err) {
alert('Uninstall failed: ' + err);
}
};
// Init
async function initialize() {
try {
const isLinux = navigator.userAgent.toLowerCase().includes('linux');
if (isLinux) {
const offlineSection = document.getElementById('offlineWhisperSection');
if (offlineSection) offlineSection.style.display = 'none';
const startupLabel = document.getElementById('startupLabel');
if (startupLabel) startupLabel.innerText = 'Run on Startup';
}
} catch (err) {
console.error('Failed to get environment:', err);
}
await loadSettings();
await loadMics();
await checkConnection();
await checkWhisperStatus();
if (window.runtime && typeof window.runtime.EventsOn === 'function') {
window.runtime.EventsOn('history:updated', function () {
refreshHistoryFromBackend();
});
}
// If Linux, ensure Local Whisper option is removed
if (navigator.userAgent.toLowerCase().includes('linux')) {
const whisperSelect = document.getElementById('whisperModel');
if (whisperSelect) {
Array.from(whisperSelect.options).forEach(opt => {
if (opt.value.startsWith('local-')) opt.remove();
});
}
}
}
initialize();
</script>
</body>
</html>