diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 38cc350..38c085c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, webContents, dialog, shell } from 'electron'; +import { ipcMain, BrowserWindow, webContents, dialog, shell, app } from 'electron'; import path from 'path'; import fs from 'fs'; import { databaseService, HistoryEntry, Bookmark, Tab } from '../services/database'; @@ -1187,4 +1187,36 @@ When Planning Mode is enabled, you have access to these tools: throw error; } }); + + // User agreement handlers + ipcMain.handle('agreement:check', async () => { + try { + const accepted = databaseService.getSetting('user-agreement-accepted'); + return accepted === 'true'; + } catch (error: any) { + console.error('agreement:check error:', error.message); + throw error; + } + }); + + ipcMain.handle('agreement:accept', async () => { + try { + databaseService.setSetting('user-agreement-accepted', 'true'); + return { success: true }; + } catch (error: any) { + console.error('agreement:accept error:', error.message); + throw error; + } + }); + + // App control handlers + ipcMain.handle('app:quit', async () => { + try { + app.quit(); + return { success: true }; + } catch (error: any) { + console.error('app:quit error:', error.message); + throw error; + } + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index e4cffd1..dbff3a2 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -34,6 +34,12 @@ const ALLOWED_INVOKE_CHANNELS = [ 'ollama:generate', 'ollama:chat', 'ollama:getStatus', + 'ollama:restart', + 'ollama:forceKill', + 'ollama:stop', + 'ollama:cancelPull', + 'ollama:cancelChat', + 'tabs:wasCrash', 'tool:search_history', 'tool:get_bookmarks', 'tool:analyze_page_content', @@ -64,6 +70,9 @@ const ALLOWED_INVOKE_CHANNELS = [ 'download:chooseSaveLocation', 'download:openManager', 'download:saveImage', + 'agreement:check', + 'agreement:accept', + 'app:quit', ]; const ALLOWED_LISTEN_CHANNELS = [ diff --git a/src/main/services/database.ts b/src/main/services/database.ts index a05ae32..6a4c814 100644 --- a/src/main/services/database.ts +++ b/src/main/services/database.ts @@ -616,13 +616,7 @@ class DatabaseService { updateDownload(id: number, updates: Partial): void { if (!this.db) throw new Error('Database not initialized'); - const allowedFields = [ - 'received_bytes', - 'state', - 'end_time', - 'error', - 'total_bytes', - ] as const; + const allowedFields = ['received_bytes', 'state', 'end_time', 'error', 'total_bytes'] as const; const fields: string[] = []; const values: any[] = []; diff --git a/src/main/services/ollama.ts b/src/main/services/ollama.ts index a1c8541..d57e9c6 100644 --- a/src/main/services/ollama.ts +++ b/src/main/services/ollama.ts @@ -663,20 +663,24 @@ export class OllamaService { private async findOllamaPid(): Promise { return new Promise((resolve) => { if (process.platform === 'win32') { - exec('tasklist /FI "IMAGENAME eq ollama.exe" /FO CSV /NH', { timeout: 3000 }, (error, stdout) => { - if (error || !stdout || stdout.includes('INFO: No tasks are running')) { - resolve(null); - return; - } + exec( + 'tasklist /FI "IMAGENAME eq ollama.exe" /FO CSV /NH', + { timeout: 3000 }, + (error, stdout) => { + if (error || !stdout || stdout.includes('INFO: No tasks are running')) { + resolve(null); + return; + } - // Parse the first ollama.exe process found - const match = stdout.match(/"ollama\.exe","(\d+)"/); - if (match && match[1]) { - resolve(parseInt(match[1], 10)); - } else { - resolve(null); + // Parse the first ollama.exe process found + const match = stdout.match(/"ollama\.exe","(\d+)"/); + if (match && match[1]) { + resolve(parseInt(match[1], 10)); + } else { + resolve(null); + } } - }); + ); } else { exec('pgrep -f "ollama"', { timeout: 3000 }, (error, stdout) => { if (error || !stdout.trim()) { @@ -734,7 +738,10 @@ export class OllamaService { try { // Parse CSV output (skip header and node line) - const lines = stdout.trim().split('\n').filter(line => line.trim()); + const lines = stdout + .trim() + .split('\n') + .filter((line) => line.trim()); if (lines.length < 2) { resolve(null); return; @@ -747,9 +754,10 @@ export class OllamaService { const workingSetSize = parseInt(parts[3], 10); // Memory in bytes // Calculate uptime (0 if we don't have start time) - const uptime = this.processStartTime > 0 - ? Math.floor((Date.now() - this.processStartTime) / 1000) - : 0; + const uptime = + this.processStartTime > 0 + ? Math.floor((Date.now() - this.processStartTime) / 1000) + : 0; resolve({ pid, @@ -786,9 +794,10 @@ export class OllamaService { const rss = parseInt(output[0], 10) * 1024; // Convert KB to bytes const cpu = parseFloat(output[1]); - const uptime = this.processStartTime > 0 - ? Math.floor((Date.now() - this.processStartTime) / 1000) - : 0; + const uptime = + this.processStartTime > 0 + ? Math.floor((Date.now() - this.processStartTime) / 1000) + : 0; resolve({ pid, @@ -844,7 +853,7 @@ export class OllamaService { // Wait for stop to complete (isStopping flag will be reset by stop()) // Add extra delay to ensure cleanup is complete - await new Promise(resolve => setTimeout(resolve, 1500)); + await new Promise((resolve) => setTimeout(resolve, 1500)); // Verify process is fully stopped before starting if (this.process) { @@ -1330,7 +1339,9 @@ export class OllamaService { // Log periodically to show stream is alive if (chunkCount % 100 === 0) { - console.log(`[Ollama] Received ${chunkCount} chunks, ${tokenCount} tokens, buffer size: ${buffer.length}`); + console.log( + `[Ollama] Received ${chunkCount} chunks, ${tokenCount} tokens, buffer size: ${buffer.length}` + ); // Debug: Show buffer content if no tokens are being extracted if (tokenCount === 0 && chunkCount >= 100) { console.log('[Ollama] DEBUG - Buffer sample:', buffer.substring(0, 200)); @@ -1344,7 +1355,11 @@ export class OllamaService { processedSomething = false; // Strategy 1: Try to parse buffer as complete JSON (for small chunks) - if (buffer.length < 500 && buffer.trim().startsWith('{') && buffer.trim().endsWith('}')) { + if ( + buffer.length < 500 && + buffer.trim().startsWith('{') && + buffer.trim().endsWith('}') + ) { try { const data = JSON.parse(buffer); processedSomething = true; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d690b03..202712e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,9 +1,30 @@ import React, { useEffect, useState } from 'react'; import { BrowserLayout } from './components/Browser/BrowserLayout'; import { DownloadManager } from './components/Downloads/DownloadManager'; +import { UserAgreement } from './components/UserAgreement/UserAgreement'; function App() { const [route, setRoute] = useState(window.location.hash); + const [agreementAccepted, setAgreementAccepted] = useState(null); + const [showAgreement, setShowAgreement] = useState(false); + + // Check if user has accepted the agreement + useEffect(() => { + const checkAgreement = async () => { + try { + const accepted = await window.electron.invoke('agreement:check'); + setAgreementAccepted(accepted); + setShowAgreement(!accepted); + } catch (error) { + console.error('Failed to check user agreement:', error); + // On error, assume agreement not accepted for safety + setAgreementAccepted(false); + setShowAgreement(true); + } + }; + + checkAgreement(); + }, []); useEffect(() => { const handleHashChange = () => { @@ -14,6 +35,48 @@ function App() { return () => window.removeEventListener('hashchange', handleHashChange); }, []); + const handleAcceptAgreement = async () => { + try { + await window.electron.invoke('agreement:accept'); + setAgreementAccepted(true); + setShowAgreement(false); + } catch (error) { + console.error('Failed to save agreement acceptance:', error); + } + }; + + const handleDeclineAgreement = async () => { + try { + // Close the application if user declines + await window.electron.invoke('app:quit'); + } catch (error) { + console.error('Failed to quit application:', error); + } + }; + + // Show loading state while checking agreement + if (agreementAccepted === null) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + // Show agreement modal if not accepted + if (!agreementAccepted) { + return ( + + ); + } + // Route to download manager if hash is #downloads if (route === '#downloads' || route === '#/downloads') { return ; diff --git a/src/renderer/components/Browser/MultiWebViewContainer.tsx b/src/renderer/components/Browser/MultiWebViewContainer.tsx index 7938ef9..c7aa115 100644 --- a/src/renderer/components/Browser/MultiWebViewContainer.tsx +++ b/src/renderer/components/Browser/MultiWebViewContainer.tsx @@ -339,127 +339,127 @@ export const MultiWebViewContainer = forwardRef((props, ref) => { <>
{tabs.map((tab) => { - const isVisible = tab.id === activeTabId; - const shouldRenderWebview = !tab.isSuspended; - - return ( -
- {/* Suspended Tab Placeholder */} - {tab.isSuspended && isVisible && ( -
-
-
- {tab.favicon ? ( - - ) : ( - - - - )} -
-

Tab Suspended

-

This tab was suspended to save memory.

-

- {tab.title || tab.url || 'No title'} -

- -
-
- )} - - {/* Welcome Screen Overlay - shown when no URL */} - {!tab.url && !tab.isSuspended && isVisible && ( -
-
- - - -

Welcome to Open Browser

-

- Enter a URL or search query in the address bar to get started. -

-

- Click the AI button to chat with local models about any page. -

- - {/* Personality Selection Button */} -
+ const isVisible = tab.id === activeTabId; + const shouldRenderWebview = !tab.isSuspended; + + return ( +
+ {/* Suspended Tab Placeholder */} + {tab.isSuspended && isVisible && ( +
+
+
+ {tab.favicon ? ( + + ) : ( + + + + )} +
+

Tab Suspended

+

This tab was suspended to save memory.

+

+ {tab.title || tab.url || 'No title'} +

-

- Customize how your AI assistant talks to you +

+
+ )} + + {/* Welcome Screen Overlay - shown when no URL */} + {!tab.url && !tab.isSuspended && isVisible && ( +
+
+ + + +

Welcome to Open Browser

+

+ Enter a URL or search query in the address bar to get started. +

+

+ Click the AI button to chat with local models about any page.

+ + {/* Personality Selection Button */} +
+ +

+ Customize how your AI assistant talks to you +

+
-
- )} - - {/* WebView - only render if not suspended and has URL */} - {shouldRenderWebview && tab.url && ( - { - if (el) { - webviewRefs.current[tab.id] = el; - // Setup listeners on mount - const cleanup = setupWebviewListeners(el, tab.id); - // Store cleanup function - (el as any).__cleanup = cleanup; - } else if (webviewRefs.current[tab.id]) { - // Cleanup on unmount - const cleanup = (webviewRefs.current[tab.id] as any).__cleanup; - if (cleanup) cleanup(); - delete webviewRefs.current[tab.id]; - } - }} - src={tab.url} - className="w-full h-full" - // @ts-ignore - webview is a custom Electron element - // Security: Use persistent partition for session data - partition="persist:main" - // Security: Disable popups to prevent popup spam and phishing - allowpopups="false" - // Security: Enable context isolation, allow javascript and plugins for full browsing - // Note: Webviews are sandboxed separately from the main renderer process - webpreferences="contextIsolation=true,javascript=yes,plugins=yes,sandbox=true" - // User agent string for compatibility - useragent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - /> - )} -
- ); - })} + )} + + {/* WebView - only render if not suspended and has URL */} + {shouldRenderWebview && tab.url && ( + { + if (el) { + webviewRefs.current[tab.id] = el; + // Setup listeners on mount + const cleanup = setupWebviewListeners(el, tab.id); + // Store cleanup function + (el as any).__cleanup = cleanup; + } else if (webviewRefs.current[tab.id]) { + // Cleanup on unmount + const cleanup = (webviewRefs.current[tab.id] as any).__cleanup; + if (cleanup) cleanup(); + delete webviewRefs.current[tab.id]; + } + }} + src={tab.url} + className="w-full h-full" + // @ts-ignore - webview is a custom Electron element + // Security: Use persistent partition for session data + partition="persist:main" + // Security: Disable popups to prevent popup spam and phishing + allowpopups="false" + // Security: Enable context isolation, allow javascript and plugins for full browsing + // Note: Webviews are sandboxed separately from the main renderer process + webpreferences="contextIsolation=true,javascript=yes,plugins=yes,sandbox=true" + // User agent string for compatibility + useragent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + /> + )} +
+ ); + })}
{/* Personality Selector Modal */} diff --git a/src/renderer/components/Chat/ChatSidebar.tsx b/src/renderer/components/Chat/ChatSidebar.tsx index 63cd105..b006ca7 100644 --- a/src/renderer/components/Chat/ChatSidebar.tsx +++ b/src/renderer/components/Chat/ChatSidebar.tsx @@ -679,9 +679,7 @@ const MessageBubble: React.FC<{ message: Message }> = ({ message }) => { /> AI Reasoning Process - - ({message.thinking.length} chars) - + ({message.thinking.length} chars)
{
{filteredDownloads.length === 0 ? (
- + { {filter === 'all' ? 'Start downloading files to see them here' : filter === 'active' - ? 'No active downloads' - : 'No completed downloads'} + ? 'No active downloads' + : 'No completed downloads'}

) : ( @@ -267,12 +264,12 @@ export const DownloadManager: React.FC = () => { download.state === 'completed' ? 'bg-green-500/20 text-green-400' : download.state === 'in_progress' - ? 'bg-blue-500/20 text-blue-400' - : download.state === 'paused' - ? 'bg-yellow-500/20 text-yellow-400' - : download.state === 'failed' - ? 'bg-red-500/20 text-red-400' - : 'bg-gray-500/20 text-gray-400' + ? 'bg-blue-500/20 text-blue-400' + : download.state === 'paused' + ? 'bg-yellow-500/20 text-yellow-400' + : download.state === 'failed' + ? 'bg-red-500/20 text-red-400' + : 'bg-gray-500/20 text-gray-400' }`} > {download.state} diff --git a/src/renderer/components/Settings/PersonalitySelector.tsx b/src/renderer/components/Settings/PersonalitySelector.tsx index 524662c..e7ce97c 100644 --- a/src/renderer/components/Settings/PersonalitySelector.tsx +++ b/src/renderer/components/Settings/PersonalitySelector.tsx @@ -35,7 +35,7 @@ export const PersonalitySelector: React.FC = ({ isOpen // Try to set category based on current personality if (current) { for (const [categoryKey, category] of Object.entries(config.categories)) { - if (category.personalities.some(p => p.id === current.id)) { + if (category.personalities.some((p) => p.id === current.id)) { setSelectedCategory(categoryKey); break; } @@ -62,7 +62,10 @@ export const PersonalitySelector: React.FC = ({ isOpen if (!isOpen) return null; return ( -
+
e.stopPropagation()} @@ -80,12 +83,7 @@ export const PersonalitySelector: React.FC = ({ isOpen className="text-muted-foreground hover:text-foreground transition-colors" title="Close" > - + = ({ isOpen }`} >
{category.name}
-
+
{category.personalities.length} personalities
@@ -177,7 +179,9 @@ export const PersonalitySelector: React.FC = ({ isOpen )}
-

{personality.name}

+

+ {personality.name} +

{personality.description}

@@ -209,7 +213,9 @@ export const PersonalitySelector: React.FC = ({ isOpen
{currentPersonality && ( - Currently active: {currentPersonality.personName} ({currentPersonality.name}) + Currently active:{' '} + {currentPersonality.personName}{' '} + ({currentPersonality.name}) )}
diff --git a/src/renderer/components/Settings/SystemPromptSettings.tsx b/src/renderer/components/Settings/SystemPromptSettings.tsx index 2a67114..651153b 100644 --- a/src/renderer/components/Settings/SystemPromptSettings.tsx +++ b/src/renderer/components/Settings/SystemPromptSettings.tsx @@ -57,7 +57,11 @@ export const SystemPromptSettings: React.FC = ({ isOp }; const handleReset = () => { - if (confirm('Are you sure you want to clear all custom settings? The base system prompt will remain active.')) { + if ( + confirm( + 'Are you sure you want to clear all custom settings? The base system prompt will remain active.' + ) + ) { setSystemPrompt(''); setUserInfo(''); setCustomInstructions(''); @@ -75,180 +79,185 @@ export const SystemPromptSettings: React.FC = ({ isOp return ( <>
-
- {/* Header */} -
-

System Prompt Settings

- -
- - {/* Content */} -
- {/* Info Banner */} -
-

- Note: A comprehensive base system prompt is always active. Your customizations below are added to the base prompt, not replacing it. -

+
+ {/* Header */} +
+

System Prompt Settings

+
- {/* AI Personality Section */} -
-
- - + {/* Content */} +
+ {/* Info Banner */} +
+

+ Note: A comprehensive base system prompt is always active. Your + customizations below are added to the base prompt, not replacing + it. +

- {currentPersonality ? ( -
-
- {getIconEmoji(currentPersonality.icon)} -
-
-

{currentPersonality.personName}

-

{currentPersonality.name}

-

{currentPersonality.description}

-
- {currentPersonality.tags.map((tag) => ( - - {tag} - - ))} + + {/* AI Personality Section */} +
+
+ + +
+ {currentPersonality ? ( +
+
+ {getIconEmoji(currentPersonality.icon)} +
+
+

{currentPersonality.personName}

+

{currentPersonality.name}

+

+ {currentPersonality.description} +

+
+ {currentPersonality.tags.map((tag) => ( + + {tag} + + ))} +
-
- ) : ( -

No personality selected

- )} -
+ ) : ( +

No personality selected

+ )} +
- {/* System Prompt */} -
- -