diff --git a/package.json b/package.json index 8bf660c..752e16c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "imposter", + "name": "system-utility", "version": "1.0.0", - "description": "Imposter: Beating a Broken System", + "description": "System Utility: System performance evaluation tool", "author": "Puskar Roy", "repository": { "type": "git", @@ -37,8 +37,8 @@ "prettier": "^3.2.5" }, "build": { - "appId": "com.imposter.app", - "productName": "Imposter", + "appId": "com.system-utility.app", + "productName": "System Utility", "directories": { "output": "dist" }, diff --git a/src/main/index.js b/src/main/index.js index 2151e71..3d28b00 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -2,6 +2,14 @@ const { app, session, BrowserWindow } = require('electron'); const path = require('path'); require('dotenv').config(); +if (app.isPackaged) { + console.log = () => {}; + console.debug = () => {}; + console.info = () => {}; + console.warn = () => {}; + console.error = () => {}; +} + const { createMainWindow, getMainWindow } = require('./window-manager'); const { registerShortcuts, unregisterShortcuts } = require('./shortcuts'); const { registerIpcHandlers } = require('./ipc-handlers'); diff --git a/src/main/ipc-handlers.js b/src/main/ipc-handlers.js index 567c8bc..c6cc31a 100644 --- a/src/main/ipc-handlers.js +++ b/src/main/ipc-handlers.js @@ -165,6 +165,21 @@ function registerIpcHandlers() { console.error('[IPC] minimize error:', err); } }); + + ipcMain.on('maximize-app', () => { + try { + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + } + } catch (err) { + console.error('[IPC] maximize error:', err); + } + }); ipcMain.on('close-app', () => { try { app.quit(); } catch (err) { @@ -197,6 +212,14 @@ function registerIpcHandlers() { } }); + ipcMain.on('register-window-listeners', (event) => { + const win = getMainWindow(); + if (win && !win.isDestroyed()) { + win.on('maximize', () => safeSendToWindow(win, 'window-state', 'maximized')); + win.on('unmaximize', () => safeSendToWindow(win, 'window-state', 'normal')); + } + }); + ipcMain.on('send-ai-to-island', (event, text) => { try { const { getIslandWindow } = require('./window-manager'); diff --git a/src/main/preload.js b/src/main/preload.js index 7551e0c..1659137 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { // Basic App Controls minimizeApp: () => ipcRenderer.send('minimize-app'), + maximizeApp: () => ipcRenderer.send('maximize-app'), closeApp: () => ipcRenderer.send('close-app'), restartApp: () => ipcRenderer.send('restart-app'), setAppMode: (mode) => ipcRenderer.send('set-app-mode', mode), @@ -38,5 +39,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Window Management openIslandWindow: () => ipcRenderer.send('open-island-window'), - closeIslandWindow: () => ipcRenderer.send('close-island-window') + closeIslandWindow: () => ipcRenderer.send('close-island-window'), + registerWindowListeners: () => ipcRenderer.send('register-window-listeners'), + onWindowState: (callback) => ipcRenderer.on('window-state', (event, state) => callback(state)) }); diff --git a/src/main/shortcuts.js b/src/main/shortcuts.js index 635a578..10adedb 100644 --- a/src/main/shortcuts.js +++ b/src/main/shortcuts.js @@ -1,5 +1,5 @@ const { globalShortcut, app, screen, desktopCapturer } = require('electron'); -const { getMainWindow, createSnipperWindow } = require('./window-manager'); +const { getMainWindow, createSnipperWindow, getIslandWindow, getSnipperWindow } = require('./window-manager'); const path = require('path'); function safeRegister(accelerator, callback) { @@ -21,8 +21,30 @@ function safeSend(channel, ...args) { } } +let isAppHidden = false; + +function toggleVisibility() { + isAppHidden = !isAppHidden; + const mainWin = getMainWindow(); + const islandWin = getIslandWindow(); + const snipperWin = getSnipperWindow(); + + const windows = [mainWin, islandWin, snipperWin]; + + windows.forEach(win => { + if (win && !win.isDestroyed()) { + if (isAppHidden) { + win.hide(); + } else { + win.show(); + if (win === mainWin) win.focus(); + } + } + }); +} + function registerShortcuts() { - safeRegister('CommandOrControl+Shift+Q', () => app.quit()); + safeRegister('CommandOrControl+Shift+Q', () => toggleVisibility()); const moveAmount = 15; const move = (dx, dy) => { @@ -78,11 +100,6 @@ function registerShortcuts() { console.error('[SHORTCUT] Snipping error:', err); } }); - - safeRegister('CommandOrControl+Shift+D', () => { - const win = getMainWindow(); - if (win && !win.isDestroyed()) win.webContents.toggleDevTools(); - }); } function unregisterShortcuts() { diff --git a/src/main/window-manager.js b/src/main/window-manager.js index 98af70e..a717ff6 100644 --- a/src/main/window-manager.js +++ b/src/main/window-manager.js @@ -48,11 +48,13 @@ function createMainWindow(preloadPath) { skipTaskbar: true, alwaysOnTop: true, hasShadow: false, - resizable: false, + resizable: true, + maximizable: true, webPreferences: { nodeIntegration: false, contextIsolation: true, - preload: preloadPath + preload: preloadPath, + devTools: false } }); @@ -96,7 +98,8 @@ function createIslandWindow(preloadPath) { webPreferences: { nodeIntegration: false, contextIsolation: true, - preload: preloadPath + preload: preloadPath, + devTools: false } }); @@ -137,7 +140,8 @@ function createSnipperWindow(preloadPath, screenSource) { enableLargerThanScreen: true, webPreferences: { preload: preloadPath, - contextIsolation: true + contextIsolation: true, + devTools: false } }); diff --git a/src/renderer/css/base.css b/src/renderer/css/base.css index 01504f7..a1c93d5 100644 --- a/src/renderer/css/base.css +++ b/src/renderer/css/base.css @@ -46,3 +46,72 @@ body { ::-webkit-scrollbar-thumb:hover { background: #666; } + +/* Custom HUD Notifications */ +.notification-container { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 10000; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + pointer-events: none; +} + +.notification { + background: rgba(30, 30, 30, 0.95); + color: var(--text-primary); + padding: 12px 20px; + border-radius: 12px; + font-size: 13px; + font-weight: 500; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + border: 1px solid var(--border-color); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + gap: 12px; + animation: slideDownIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; + pointer-events: auto; + max-width: 400px; + width: max-content; +} + +.notification.error { + border-left: 4px solid #ff4d4d; +} + +.notification.success { + border-left: 4px solid var(--accent-color); +} + +.notification-icon { + display: flex; + align-items: center; + justify-content: center; +} + +@keyframes slideDownIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.notification.fade-out { + animation: fadeOut 0.3s forwards; +} + +@keyframes fadeOut { + to { + opacity: 0; + transform: translateY(-10px); + } +} diff --git a/src/renderer/css/components/split-view.css b/src/renderer/css/components/split-view.css new file mode 100644 index 0000000..7be13ec --- /dev/null +++ b/src/renderer/css/components/split-view.css @@ -0,0 +1,223 @@ +.split-thread { + display: none; + flex-direction: row; + height: 100%; + width: 100%; + overflow: hidden; + gap: 0; +} + +.split-thread.active { + display: flex; +} + +.split-column { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + height: 100%; + background: rgba(255, 255, 255, 0.02); +} + +.split-column.left { + border-right: none; +} + +.column-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + background: rgba(0, 0, 0, 0.2); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.split-messages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.split-divider { + width: 1px; + background: var(--border-color); + position: relative; + display: flex; + align-items: center; + justify-content: center; + z-index: 5; +} + +.v-pill { + position: absolute; + top: 10px; + background: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-weight: 800; + font-size: 10px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + transform: translateY(12px); +} + +/* Response Label Style from Mockup */ +.model-response-label { + font-size: 11px; + font-weight: 700; + color: var(--accent-color); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 8px; + opacity: 0.8; +} + +/* Adjustments for chat bubbles in split view */ +.split-messages .chat-message-ai, +.split-messages .chat-message-user { + max-width: 100%; + margin-left: 0; + margin-right: 0; + font-size: 13px; + line-height: 1.5; + padding: 12px 16px; + border-radius: 12px; +} + +.split-messages .chat-message-ai { + background: rgba(255, 255, 255, 0.04); +} + +/* Fix for Thinking box (loading state) */ +.split-messages .chat-message-ai:has(.loading) { + background: transparent; + padding: 0 12px; + margin-bottom: 12px; +} + +.split-messages .loading { + margin-top: 0; + background: rgba(255, 255, 255, 0.06); + padding: 10px 16px; + border-radius: 20px; + display: inline-flex; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 4px 15px rgba(0,0,0,0.2); +} + +.split-messages .loading span { + font-size: 13px; + letter-spacing: 0.02em; +} + +/* Scrollbar styling for split view - Sleeker */ +.split-messages::-webkit-scrollbar { + width: 3px; +} + +.split-messages::-webkit-scrollbar-track { + background: transparent; +} + +.split-messages::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 10px; +} + +.split-messages::-webkit-scrollbar-thumb:hover { + background: var(--accent-color); +} + +/* Dropdown Overrides for Split Headers */ +.column-header .custom-dropdown { + max-width: 280px; +} + +.column-header .dropdown-trigger { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + height: 36px; + padding: 0 14px; + border-radius: 10px; +} + +.column-header .dropdown-trigger:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.1); +} + +.column-header .dropdown-value { + font-weight: 600; + font-size: 12px; + color: var(--text-primary); +} + +/* Speed and Performance Badges */ +.speed-badge { + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.05); + padding: 2px 6px; + border-radius: 4px; + margin-left: 8px; + vertical-align: middle; +} + +.faster-badge { + color: #ff9d00; + background: rgba(255, 157, 0, 0.1); + border: 1px solid rgba(255, 157, 0, 0.2); + font-size: 9px; + padding: 1px 5px; + text-transform: uppercase; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { opacity: 0.8; } + 50% { opacity: 1; transform: scale(1.05); } + 100% { opacity: 0.8; } +} + +.split-title-header { + font-family: 'Outfit', sans-serif; + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: 0.03em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 8px; +} + +.split-title-header::before { + content: ''; + display: block; + width: 3px; + height: 14px; + background: var(--accent-color); + border-radius: 10px; +} + +/* Animations */ +.split-thread.active { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/src/renderer/css/layout.css b/src/renderer/css/layout.css index 9005398..40069f8 100644 --- a/src/renderer/css/layout.css +++ b/src/renderer/css/layout.css @@ -4,10 +4,24 @@ height: 100%; width: 100%; max-width: 900px; + width: 95vw; background-color: var(--bg-color); border-radius: 12px; overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + transition: max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.split-mode-active .app-container { + max-width: 1400px; +} + +body.is-maximized .app-container { + max-width: 100% !important; + width: 100% !important; + height: 100vh !important; + border-radius: 0; + box-shadow: none; } .app-header { @@ -78,7 +92,8 @@ transition: all 0.2s; } -.win-btn.win-min:hover { +.win-btn.win-min:hover, +.win-btn.win-max:hover { background-color: rgba(255, 255, 255, 0.1); color: var(--text-primary); } diff --git a/src/renderer/index.html b/src/renderer/index.html index ea1f623..832eb66 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ - Imposter + System Utility + @@ -51,6 +52,12 @@ + + +

Model Management

@@ -541,8 +591,8 @@

Global Keyboard Controls

- Quit - Application + Toggle Visibility + (Boss Key) Ctrl + Shift + Q
@@ -659,6 +709,7 @@

Global Keyboard Controls

+
diff --git a/src/renderer/js/app.js b/src/renderer/js/app.js index ef39a64..ea4e2b0 100644 --- a/src/renderer/js/app.js +++ b/src/renderer/js/app.js @@ -9,6 +9,16 @@ window.onerror = (message, source, lineno, colno, error) => { return true; }; +// Global Log Scrubber +const isDev = false; // Set to true for development +if (!isDev) { + console.log = () => {}; + console.debug = () => {}; + console.info = () => {}; + console.warn = () => {}; + console.error = () => {}; +} + window.onunhandledrejection = (event) => { event.preventDefault(); }; @@ -31,6 +41,8 @@ let settingsPersona; let settingsAppMode; let newModelProvider; let newGeminiModelSelect; +let leftModelSelect; +let rightModelSelect; // Overlays const onboardingOverlay = $('onboarding-overlay'); @@ -78,10 +90,15 @@ let customModels = []; let editingIndex = -1; // -1 means no model is currently being edited let currentRawResponse = ''; let isRecording = false; +let isSplitView = false; // Context Memory let conversationHistory = []; +let leftHistory = []; +let rightHistory = []; let emptyStateHtml = ''; +let leftEmptyStateHtml = ''; +let rightEmptyStateHtml = ''; async function init() { try { @@ -89,6 +106,8 @@ async function init() { loadAppConfig(); emptyStateHtml = resultContent ? resultContent.innerHTML : ''; + leftEmptyStateHtml = $('left-result') ? $('left-result').innerHTML : ''; + rightEmptyStateHtml = $('right-result') ? $('right-result').innerHTML : ''; customModels = Config.getSavedModels(); applyAppMode(userConfig.appMode || 'stealth'); @@ -96,6 +115,17 @@ async function init() { setupEventListeners(); renderCustomModelsList(); + if (window.electronAPI) { + window.electronAPI.registerWindowListeners(); + window.electronAPI.onWindowState((state) => { + if (state === 'maximized') { + document.body.classList.add('is-maximized'); + } else { + document.body.classList.remove('is-maximized'); + } + }); + } + loadModels(); } catch (err) { console.error('[INIT] Setup failed:', err); @@ -104,14 +134,16 @@ async function init() { function applyAppMode(mode) { try { + scrubTooltips(mode); + + // Fortress Mode: Disable mouse resizing in Stealth Mode if (window.electronAPI && window.electronAPI.setAppMode) { window.electronAPI.setAppMode(mode); } + if (windowControls) { windowControls.style.display = mode === 'normal' ? 'flex' : 'none'; } - - scrubTooltips(mode); // Update dropdown if initialized if (settingsAppMode) settingsAppMode.setValue(mode); @@ -171,6 +203,17 @@ function initCustomDropdowns() { placeholder: 'Awaiting API Verification...', options: [] }); + + // 6. Split-View Model Selectors + leftModelSelect = new CustomDropdown('leftModelSelect', { + placeholder: 'Select Primary...', + onChange: () => {} + }); + + rightModelSelect = new CustomDropdown('rightModelSelect', { + placeholder: 'Select Verification...', + onChange: () => {} + }); } function handleProviderChange(provider) { @@ -199,22 +242,65 @@ function handleProviderChange(provider) { } } +let tooltipObserver = null; + function scrubTooltips(mode) { try { - const elements = document.querySelectorAll('[title], [data-stealth-title]'); - elements.forEach(el => { - if (mode === 'stealth' || (userConfig && userConfig.appMode === 'stealth')) { - if (el.hasAttribute('title')) { - el.setAttribute('data-stealth-title', el.getAttribute('title')); - el.removeAttribute('title'); - } - } else { - if (el.hasAttribute('data-stealth-title')) { - el.setAttribute('title', el.getAttribute('data-stealth-title')); - el.removeAttribute('data-stealth-title'); + const isStealth = mode === 'stealth' || (userConfig && userConfig.appMode === 'stealth'); + + const performScrub = (root = document) => { + const elements = root.querySelectorAll ? root.querySelectorAll('[title], [data-tooltip]') : []; + elements.forEach(el => { + if (isStealth) { + if (el.hasAttribute('title')) { + el.setAttribute('data-tooltip', el.getAttribute('title')); + el.removeAttribute('title'); + } + } else { + if (el.hasAttribute('data-tooltip')) { + el.setAttribute('title', el.getAttribute('data-tooltip')); + el.removeAttribute('data-tooltip'); + } } - } - }); + }); + }; + + // Initial scrub + performScrub(); + + // Fortress Mode: Aggressive MutationObserver for dynamic elements + if (tooltipObserver) tooltipObserver.disconnect(); + + if (isStealth) { + tooltipObserver = new MutationObserver((mutations) => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.nodeType === 1) { // Element node + performScrub(node); + // Also check the node itself + if (node.hasAttribute('title')) { + node.setAttribute('data-tooltip', node.getAttribute('title')); + node.removeAttribute('title'); + } + } + }); + if (mutation.type === 'attributes' && mutation.attributeName === 'title' && isStealth) { + const el = mutation.target; + if (el.hasAttribute('title')) { + el.setAttribute('data-tooltip', el.getAttribute('title')); + el.removeAttribute('title'); + } + } + }); + }); + + tooltipObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['title'] + }); + } } catch (err) { console.error('[APP] Tooltip scrubbing error:', err); } @@ -278,6 +364,20 @@ async function loadModels() { } else { if (statusText) statusText.textContent = 'No Models'; } + + // Sync Split-View dropdowns + if (leftModelSelect) leftModelSelect.setOptions(modelOptions); + if (rightModelSelect) rightModelSelect.setOptions(modelOptions); + + // Set defaults if empty + if (modelOptions.length > 0) { + if (leftModelSelect && !leftModelSelect.getValue()) leftModelSelect.setValue(modelOptions[0].value); + if (rightModelSelect && !rightModelSelect.getValue() && modelOptions.length > 1) { + rightModelSelect.setValue(modelOptions[1].value); + } else if (rightModelSelect && !rightModelSelect.getValue()) { + rightModelSelect.setValue(modelOptions[0].value); + } + } } } catch (err) { console.error('[APP] Model loading error:', err); @@ -487,6 +587,64 @@ function renderCustomModelsList() { function setupEventListeners() { if (promptInput) promptInput.addEventListener('input', () => UI.autoGrowTextarea(promptInput)); + const splitViewBtn = $('split-view-btn'); + if (splitViewBtn) { + splitViewBtn.addEventListener('click', () => { + isSplitView = !isSplitView; + document.body.classList.toggle('split-mode-active', isSplitView); + + const standardThread = $('result-content'); + const splitThread = $('split-content'); + + if (isSplitView) { + if (standardThread) standardThread.style.display = 'none'; + if (splitThread) splitThread.classList.add('active'); + splitViewBtn.classList.add('active'); + splitViewBtn.style.color = 'var(--accent-color)'; + splitViewBtn.style.opacity = '1'; + + // Header Title Switch + const selectorWrapper = document.querySelector('.model-selector-wrapper'); + if (selectorWrapper) { + selectorWrapper.style.display = 'none'; + if (!document.getElementById('split-title')) { + const title = document.createElement('div'); + title.id = 'split-title'; + title.className = 'split-title-header'; + title.textContent = 'Multi-Model Compare'; + selectorWrapper.parentNode.insertBefore(title, selectorWrapper); + } else { + document.getElementById('split-title').style.display = 'flex'; + } + } + + // If switching to split view and we have models, set defaults if not set + if (modelSelect && !leftModelSelect.getValue()) { + leftModelSelect.setValue(modelSelect.getValue()); + } + } else { + if (standardThread) standardThread.style.display = 'block'; + if (splitThread) splitThread.classList.remove('active'); + splitViewBtn.classList.remove('active'); + splitViewBtn.style.color = ''; + splitViewBtn.style.opacity = '0.6'; + + const selectorWrapper = document.querySelector('.model-selector-wrapper'); + const splitTitle = document.getElementById('split-title'); + if (selectorWrapper) selectorWrapper.style.display = 'flex'; + if (splitTitle) splitTitle.style.display = 'none'; + } + }); + } + + const winMinBtn = $('win-min-btn'); + const winMaxBtn = $('win-max-btn'); + const winCloseBtn = $('win-close-btn'); + + if (winMinBtn) winMinBtn.addEventListener('click', () => window.electronAPI.minimizeApp()); + if (winMaxBtn) winMaxBtn.addEventListener('click', () => window.electronAPI.maximizeApp()); + if (winCloseBtn) winCloseBtn.addEventListener('click', () => window.electronAPI.closeApp()); + // Dropdown listeners are handled via callbacks in initCustomDropdowns() // Manual fetch/verify for Gemini @@ -500,7 +658,7 @@ function setupEventListeners() { async function fetchGeminiModelsForForm() { const key = newModelKey ? newModelKey.value.trim() : ''; if (!key || !newGeminiModelSelect) { - alert('Please enter your Gemini API key first.'); + UI.showNotification('Please enter your Gemini API key first.', 'error'); return; } @@ -579,12 +737,18 @@ function setupEventListeners() { const resetAppBtn = $('reset-app-btn'); if (resetAppBtn) { resetAppBtn.addEventListener('click', () => { - const confirmed = confirm('CRITICAL WARNING: This will permanently delete ALL configurations, API keys, and models. You will be redirected to the onboarding screen. Proceed?'); - - if (confirmed) { - localStorage.clear(); - window.location.reload(); - } + // Removed native confirm() to prevent OS-level leak. + // Action is now immediate but restricted to the Settings -> Profile tab. + localStorage.clear(); + window.location.reload(); + }); + } + + const quitAppBtn = $('quit-app-btn'); + if (quitAppBtn) { + quitAppBtn.addEventListener('click', () => { + // Removed native confirm() to prevent OS-level leak. + window.electronAPI.closeApp(); }); } @@ -616,7 +780,7 @@ function setupEventListeners() { newGeminiModelSelect.setOptions([]); } } else if (!modelId && provider === 'gemini') { - alert('Please enter a valid API key and select a model from the list.'); + UI.showNotification('Please enter a valid API key and select a model from the list.', 'error'); } } catch (err) { console.error('[APP] Add model error:', err); @@ -897,23 +1061,49 @@ function setupEventListeners() { clearChatBtn.addEventListener('click', () => { try { conversationHistory = []; + leftHistory = []; + rightHistory = []; if (resultContent) { resultContent.innerHTML = emptyStateHtml; UI.updateGreeting(resultContent.querySelector('#greeting-container'), userConfig.name); } + const leftRes = $('left-result'); + const rightRes = $('right-result'); + if (leftRes) leftRes.innerHTML = leftEmptyStateHtml; + if (rightRes) rightRes.innerHTML = rightEmptyStateHtml; } catch (err) { console.error('[APP] Clear chat error:', err); } }); } + + // Fortress Mode: Security Listeners + window.addEventListener('contextmenu', (e) => { + // Only block if we aren't in a specific dev-mode context if desired, + // but for stealth we block it everywhere. + e.preventDefault(); + }); + + window.addEventListener('dragstart', (e) => { + // Prevent ghost images during drag to stop OS "shadows" from appearing + e.preventDefault(); + }); } -function appendChatMessage(msg) { - if (!msg || msg.role === 'system' || !resultContent) return null; +function appendChatMessage(msg, container) { + const target = container || resultContent; + if (!msg || msg.role === 'system' || !target) return null; try { - if (conversationHistory.length === 1 && conversationHistory[0] === msg) { - resultContent.innerHTML = ''; + const isSplit = target !== resultContent; + + // Clear empty state if this is the first message + if (isSplit) { + if (target.querySelector('.empty-state')) target.innerHTML = ''; + } else { + if (conversationHistory.length === 1 && conversationHistory[0] === msg) { + target.innerHTML = ''; + } } const bubble = document.createElement('div'); @@ -922,16 +1112,28 @@ function appendChatMessage(msg) { bubble.textContent = msg.content || ''; } else { bubble.className = 'chat-message-ai markdown-body'; + let htmlContent = parseMarkdown(msg.content || ''); if (msg.reasoningHtml) htmlContent = msg.reasoningHtml + htmlContent; + if (isSplit && msg.modelLabel) { + let badgeHtml = ''; + if (msg.duration) { + badgeHtml = ` ${msg.duration}s`; + if (msg.isFaster) { + badgeHtml += ` ⚡ FASTEST`; + } + } + htmlContent = `
Response from ${msg.modelLabel}${badgeHtml}
` + htmlContent; + } + if (msg.isError) { UI.showError(bubble, { message: msg.content }); } else { bubble.innerHTML = htmlContent; } } - resultContent.appendChild(bubble); + target.appendChild(bubble); return bubble; } catch (err) { console.error('[APP] Chat message render error:', err); @@ -941,17 +1143,109 @@ function appendChatMessage(msg) { async function performSearch(isF10 = false) { if (!searchBtn || searchBtn.disabled) return; - if (!promptInput || !modelSelect) return; + if (!promptInput) return; const text = promptInput.value.trim(); - const modelSelection = modelSelect.getValue(); - if (!text || !modelSelection) return; + if (!text) return; - const parts = modelSelection.split('|'); - const provider = parts[0] || ''; - const modelId = parts[1] || ''; - const baseUrl = parts[2] || ''; - const apiKey = parts[3] || ''; + if (isSplitView) { + await handleSplitSearch(text); + } else { + await handleStandardSearch(text, isF10); + } +} + +async function handleSplitSearch(text) { + if (!leftModelSelect || !rightModelSelect) return; + + const leftModelSelection = leftModelSelect.getValue(); + const rightModelSelection = rightModelSelect.getValue(); + + if (!leftModelSelection || !rightModelSelection) { + UI.showNotification('Please select both models for Split-View comparison.', 'error'); + return; + } + + promptInput.value = ''; + promptInput.style.height = 'auto'; + searchBtn.disabled = true; + + const leftContainer = $('left-result'); + const rightContainer = $('right-result'); + + const userMsg = { role: 'user', content: text }; + leftHistory.push(userMsg); + rightHistory.push(userMsg); + + appendChatMessage(userMsg, leftContainer); + appendChatMessage(userMsg, rightContainer); + + // Show loading on both + const leftLoading = document.createElement('div'); + leftLoading.className = 'chat-message-ai'; + UI.showLoading(leftLoading); + leftContainer.appendChild(leftLoading); + + const rightLoading = document.createElement('div'); + rightLoading.className = 'chat-message-ai'; + UI.showLoading(rightLoading); + rightContainer.appendChild(rightLoading); + + const scroll = () => { + leftContainer.scrollTop = leftContainer.scrollHeight; + rightContainer.scrollTop = rightContainer.scrollHeight; + }; + setTimeout(scroll, 10); + + let firstFinishedId = null; + const startTime = performance.now(); + + const processModelSide = async (selection, history, container, loadingEl) => { + try { + const res = await callModelApi(selection, history); + const duration = ((performance.now() - startTime) / 1000).toFixed(1); + + if (!firstFinishedId) { + firstFinishedId = selection; + } + + if (loadingEl.parentNode) loadingEl.parentNode.removeChild(loadingEl); + + const aiMsg = { + role: 'assistant', + content: res.response, + reasoningHtml: res.reasoningHtml, + modelLabel: res.modelLabel, + duration: duration, + isFaster: firstFinishedId === selection + }; + history.push(aiMsg); + appendChatMessage(aiMsg, container); + container.scrollTop = container.scrollHeight; + } catch (err) { + console.error(`[SPLIT] Error on side:`, err); + if (loadingEl.parentNode) loadingEl.parentNode.removeChild(loadingEl); + const errorMsg = { role: 'assistant', content: err.message, isError: true }; + appendChatMessage(errorMsg, container); + } + }; + + try { + await Promise.all([ + processModelSide(leftModelSelection, leftHistory, leftContainer, leftLoading), + processModelSide(rightModelSelection, rightHistory, rightContainer, rightLoading) + ]); + } catch (err) { + console.error('[SPLIT] Parallel processing error:', err); + } finally { + searchBtn.disabled = false; + setTimeout(scroll, 100); + } +} + +async function handleStandardSearch(text, isF10 = false) { + const modelSelection = modelSelect.getValue(); + if (!modelSelection) return; promptInput.value = ''; promptInput.style.height = 'auto'; @@ -964,7 +1258,9 @@ async function performSearch(isF10 = false) { if (conversationHistory.length === 0) { if (resultContent) resultContent.innerHTML = ''; const fullSystemPrompt = Config.buildSystemPrompt(userConfig); - if (fullSystemPrompt && provider === 'ollama') { + // Ollama specific system prompt handling + const parts = modelSelection.split('|'); + if (fullSystemPrompt && parts[0] === 'ollama') { conversationHistory.push({ role: 'system', content: fullSystemPrompt }); } } @@ -978,39 +1274,9 @@ async function performSearch(isF10 = false) { UI.showLoading(loadingBubble); if (resultContent) resultContent.appendChild(loadingBubble); setTimeout(() => { if (chatStage) chatStage.scrollTop = chatStage.scrollHeight; }, 10); - if (chatStage) chatStage.scrollTop = chatStage.scrollHeight; - let reasoningHtml = ''; - - if (provider === 'ollama') { - const responseData = await API.generateOllamaResponse(baseUrl, { - model: modelId, - messages: conversationHistory, - stream: false - }); - currentRawResponse = responseData?.message?.content || responseData?.response || ''; - } else if (provider === 'openrouter') { - const orMessages = conversationHistory.filter(m => m.role !== 'system'); - const fullSystemPrompt = Config.buildSystemPrompt(userConfig); - const data = await API.generateOpenRouterResponse(apiKey, { - model: modelId, - messages: orMessages, - system_prompt: fullSystemPrompt || undefined, - reasoning: { enabled: true } - }); - if (data && data.choices && data.choices[0]) { - const msg = data.choices[0].message; - currentRawResponse = msg ? (msg.content || '') : ''; - if (msg && msg.reasoning_details) reasoningHtml = UI.renderReasoningTrace(msg.reasoning_details); - } - } else if (provider === 'gemini') { - const fullSystemPrompt = Config.buildSystemPrompt(userConfig); - const data = await API.generateGeminiResponse(apiKey, modelId, conversationHistory, fullSystemPrompt); - if (data && data.candidates && data.candidates[0] && data.candidates[0].content) { - const msg = data.candidates[0].content; - currentRawResponse = (msg.parts && msg.parts[0]) ? msg.parts[0].text : ''; - } - } + const { response, reasoningHtml } = await callModelApi(modelSelection, conversationHistory); + currentRawResponse = response; const aiMsg = { role: 'assistant', @@ -1030,18 +1296,10 @@ async function performSearch(isF10 = false) { } catch (err) { console.error('[APP] Search error:', err); - const errorMsg = { - role: 'assistant', - content: err.message || 'An unexpected error occurred', - isError: true - }; + const errorMsg = { role: 'assistant', content: err.message || 'An unexpected error occurred', isError: true }; conversationHistory.push(errorMsg); - - if (loadingBubble && loadingBubble.parentNode) { - loadingBubble.parentNode.removeChild(loadingBubble); - } + if (loadingBubble && loadingBubble.parentNode) loadingBubble.parentNode.removeChild(loadingBubble); appendChatMessage(errorMsg); - if (isF10) { try { window.electronAPI.sendAiResponseToIsland('Error: Could not reach AI'); } catch (_) { } } @@ -1053,4 +1311,47 @@ async function performSearch(isF10 = false) { } } +async function callModelApi(selection, history) { + const parts = selection.split('|'); + const provider = parts[0] || ''; + const modelId = parts[1] || ''; + const baseUrl = parts[2] || ''; + const apiKey = parts[3] || ''; + + let response = ''; + let reasoningHtml = ''; + + const fullSystemPrompt = Config.buildSystemPrompt(userConfig); + + if (provider === 'ollama') { + const data = await API.generateOllamaResponse(baseUrl, { + model: modelId, + messages: history, + stream: false + }); + response = data?.message?.content || data?.response || ''; + } else if (provider === 'openrouter') { + const orMessages = history.filter(m => m.role !== 'system'); + const data = await API.generateOpenRouterResponse(apiKey, { + model: modelId, + messages: orMessages, + system_prompt: fullSystemPrompt || undefined, + reasoning: { enabled: true } + }); + if (data && data.choices && data.choices[0]) { + const msg = data.choices[0].message; + response = msg ? (msg.content || '') : ''; + if (msg && msg.reasoning_details) reasoningHtml = UI.renderReasoningTrace(msg.reasoning_details); + } + } else if (provider === 'gemini') { + const data = await API.generateGeminiResponse(apiKey, modelId, history, fullSystemPrompt); + if (data && data.candidates && data.candidates[0] && data.candidates[0].content) { + const msg = data.candidates[0].content; + response = (msg.parts && msg.parts[0]) ? msg.parts[0].text : ''; + } + } + + return { response, reasoningHtml, modelLabel: modelId }; +} + init(); diff --git a/src/renderer/js/ui.js b/src/renderer/js/ui.js index ad04ca1..56ab364 100644 --- a/src/renderer/js/ui.js +++ b/src/renderer/js/ui.js @@ -73,3 +73,37 @@ export function renderReasoningTrace(details) { return ''; } } + +export function showNotification(message, type = 'info', duration = 4000) { + const container = document.getElementById('notification-container'); + if (!container) return; + + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + + let icon = ''; + if (type === 'error') { + icon = ''; + } else if (type === 'success') { + icon = ''; + } else { + icon = ''; + } + + notification.innerHTML = ` +
${icon}
+
${message}
+ `; + + container.appendChild(notification); + + // Auto-remove + setTimeout(() => { + notification.classList.add('fade-out'); + setTimeout(() => { + if (notification.parentNode) { + container.removeChild(notification); + } + }, 300); + }, duration); +}