diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 1eac00e3e..f3ed39b50 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -27,6 +27,7 @@ "electron-squirrel-startup": "^1.0.1", "express": "^4.21.1", "framer-motion": "^11.11.11", + "linkifyjs": "^4.1.4", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -7963,6 +7964,11 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkifyjs": { + "version": "4.1.4", + "resolved": "https://artifactory.global.square/artifactory/api/npm/square-npm/linkifyjs/-/linkifyjs-4.1.4.tgz", + "integrity": "sha512-0/NxkHNpiJ0k9VrYCkAn9OtU1eu8xEr1tCCpDtSsVRm/SF0xAak2Gzv3QimSfgUgqLBCDlfhMbu73XvaEHUTPQ==" + }, "node_modules/listr2": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.2.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 66811a171..b035de087 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -54,6 +54,7 @@ "electron-squirrel-startup": "^1.0.1", "express": "^4.21.1", "framer-motion": "^11.11.11", + "linkifyjs": "^4.1.4", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index eaa7b8ce5..571717b69 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -158,7 +158,7 @@ function ChatContent({ {message.role === 'user' ? ( ) : ( - + )} ))} diff --git a/ui/desktop/src/bin/goosed b/ui/desktop/src/bin/goosed index c22131491..47ecae731 100755 Binary files a/ui/desktop/src/bin/goosed and b/ui/desktop/src/bin/goosed differ diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 218c7f74b..60125413d 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,24 +1,49 @@ import React from 'react' import ToolInvocation from './ToolInvocation' import ReactMarkdown from 'react-markdown' +import LinkPreview from './LinkPreview' +import { extractUrls } from '../utils/urlUtils' +export default function GooseMessage({ message, messages }) { + // Find the preceding user message + const messageIndex = messages.findIndex(msg => msg.id === message.id); + const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null; -export default function GooseMessage({ message }) { + // Get URLs from previous user message (if it exists) + const previousUrls = previousMessage + ? extractUrls(previousMessage.content) + : []; + + // Extract URLs from current message, excluding those from the previous user message + const urls = extractUrls(message.content, previousUrls); + + console.log('Goose message URLs:', urls); + console.log('Previous user message URLs:', previousUrls); return ( -
-
- {message.toolInvocations ? ( -
- {message.toolInvocations.map((toolInvocation) => ( - +
+
+
+ {message.toolInvocations ? ( +
+ {message.toolInvocations.map((toolInvocation) => ( + + ))} +
+ ) : ( + {message.content} + )} +
+ + {urls.length > 0 && ( +
+ {urls.map((url, index) => ( + ))}
- ) : ( - {message.content} )}
diff --git a/ui/desktop/src/components/LinkPreview.tsx b/ui/desktop/src/components/LinkPreview.tsx new file mode 100644 index 000000000..a346fa9a3 --- /dev/null +++ b/ui/desktop/src/components/LinkPreview.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from 'react'; +import { Card } from './ui/card'; + +interface Metadata { + title?: string; + description?: string; + favicon?: string; + image?: string; + url: string; +} + +interface LinkPreviewProps { + url: string; +} + +async function fetchMetadata(url: string): Promise { + console.log('🔄 Fetching metadata for URL:', url); + + try { + // Fetch the HTML content using the main process + const html = await window.electron.fetchMetadata(url); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const baseUrl = new URL(url); + + // Extract title + const title = + doc.querySelector('title')?.textContent || + doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); + + // Extract description + const description = + doc.querySelector('meta[name="description"]')?.getAttribute('content') || + doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); + + // Extract favicon + const faviconLink = + doc.querySelector('link[rel="icon"]') || + doc.querySelector('link[rel="shortcut icon"]') || + doc.querySelector('link[rel="apple-touch-icon"]') || + doc.querySelector('link[rel="apple-touch-icon-precomposed"]'); + + let favicon = faviconLink?.getAttribute('href'); + if (favicon) { + favicon = new URL(favicon, baseUrl).toString(); + } else { + // Fallback to /favicon.ico + favicon = new URL('/favicon.ico', baseUrl).toString(); + } + + // Extract OpenGraph image + let image = doc.querySelector('meta[property="og:image"]')?.getAttribute('content'); + if (image) { + image = new URL(image, baseUrl).toString(); + } + + console.log('✨ Extracted metadata:', { title, description, favicon, image }); + + return { + title: title || url, + description, + favicon, + image, + url + }; + } catch (error) { + console.error('❌ Error fetching metadata:', error); + return { + title: url, + description: undefined, + favicon: undefined, + image: undefined, + url + }; + } +} + +export default function LinkPreview({ url }: LinkPreviewProps) { + const [metadata, setMetadata] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + console.log('🔄 LinkPreview mounting for URL:', url); + let mounted = true; + + const fetchData = async () => { + try { + const data = await fetchMetadata(url); + if (mounted) { + console.log('✨ Received metadata:', data); + setMetadata(data); + } + } catch (error) { + if (mounted) { + console.error('❌ Failed to fetch metadata:', error); + setError(error.message || 'Failed to fetch metadata'); + } + } finally { + if (mounted) { + setLoading(false); + } + } + }; + + fetchData(); + return () => { mounted = false; }; + }, [url]); + + if (loading) { + return null; + } + + if (error) { + return null; + } + + if (!metadata || !metadata.title) { + return null; + } + + return ( + { + console.log('🔗 Opening URL in Chrome:', url); + window.electron.openInChrome(url); + }} + > + {metadata.favicon && ( + Site favicon { + e.currentTarget.style.display = 'none'; + }} + /> + )} +
+

{metadata.title || url}

+ {metadata.description && ( +

{metadata.description}

+ )} +
+ {metadata.image && ( + Preview { + e.currentTarget.style.display = 'none'; + }} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 33b0d12f4..4724f0b92 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -1,13 +1,27 @@ - import React from 'react' import ReactMarkdown from 'react-markdown' +import LinkPreview from './LinkPreview' +import { extractUrls } from '../utils/urlUtils' export default function UserMessage({ message }) { + // Extract URLs from current message + const urls = extractUrls(message.content, []); // No previous URLs to check against + console.log('User message URLs:', urls); + return (
-
- {message.content} +
+
+ {message.content} +
+ {urls.length > 0 && ( +
+ {urls.map((url, index) => ( + + ))} +
+ )}
- ) -}; + ); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index cea330286..4948f6a23 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -6,6 +6,7 @@ import { start as startGoosed } from './goosed'; import started from "electron-squirrel-startup"; import log from './utils/logger'; import { getPort } from './utils/portUtils'; +import { exec } from 'child_process'; // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) app.quit(); @@ -61,8 +62,6 @@ let windowCounter = 0; const windowMap = new Map(); const createChat = (query?: string) => { - const isDev = process.env.NODE_ENV === 'development'; - const mainWindow = new BrowserWindow({ titleBarStyle: 'hidden', trafficLightPosition: { x: 16, y: 18 }, @@ -203,7 +202,6 @@ app.whenReady().then(async () => { createTray(); createChat(); - // Show launcher input on key combo globalShortcut.register('Control+Alt+Command+G', createLauncher); @@ -217,12 +215,42 @@ app.whenReady().then(async () => { createChat(query); }); - - ipcMain.on('logInfo', (_, info) => { log.info("from renderer:", info); }); - + + // Handle metadata fetching from main process + ipcMain.handle('fetch-metadata', async (_, url) => { + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; Goose/1.0)' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } catch (error) { + console.error('Error fetching metadata:', error); + throw error; + } + }); + + ipcMain.on('open-in-chrome', (_, url) => { + // On macOS, use the 'open' command with Chrome + if (process.platform === 'darwin') { + exec(`open -a "Google Chrome" "${url}"`); + } else if (process.platform === 'win32') { + // On Windows, use start command + exec(`start chrome "${url}"`); + } else { + // On Linux, use xdg-open with chrome + exec(`xdg-open "${url}"`); + } + }); }); // Quit when all windows are closed, except on macOS. diff --git a/ui/desktop/src/preload.js b/ui/desktop/src/preload.js index 048aa43ae..2f38b6202 100644 --- a/ui/desktop/src/preload.js +++ b/ui/desktop/src/preload.js @@ -1,6 +1,5 @@ const { contextBridge, ipcRenderer } = require('electron') - let windowId = null; // Listen for window ID from main process @@ -15,12 +14,14 @@ contextBridge.exposeInMainWorld('appConfig', { getAll: () => config, }); - contextBridge.exposeInMainWorld('electron', { hideWindow: () => ipcRenderer.send('hide-window'), createChatWindow: (query) => ipcRenderer.send('create-chat-window', query), resizeWindow: (width, height) => ipcRenderer.send('resize-window', { windowId, width, height }), getWindowId: () => windowId, logInfo: (txt) => ipcRenderer.send('logInfo', txt), + createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query), + openInChrome: (url) => ipcRenderer.send('open-in-chrome', url), + fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url), }) diff --git a/ui/desktop/src/utils/urlUtils.ts b/ui/desktop/src/utils/urlUtils.ts new file mode 100644 index 000000000..3e3219bde --- /dev/null +++ b/ui/desktop/src/utils/urlUtils.ts @@ -0,0 +1,60 @@ +import * as linkify from 'linkifyjs'; + +// Helper to normalize URLs for comparison +function normalizeUrl(url: string): string { + try { + const parsed = new URL(url.toLowerCase()); + // Remove trailing slashes and normalize protocol + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/$/, '')}${parsed.search}${parsed.hash}`; + } catch { + // If URL parsing fails, just lowercase it + return url.toLowerCase(); + } +} + +export function extractUrls(content: string, previousUrls: string[] = []): string[] { + // First extract markdown-style links using regex + const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + const markdownMatches = Array.from(content.matchAll(markdownLinkRegex)); + const markdownUrls = markdownMatches.map(match => match[2]); + + // Then use linkifyjs to find regular URLs + const links = linkify.find(content); + + // Get URLs from current content + const linkifyUrls = links + .filter(link => link.type === 'url') + .map(link => link.href); + + // Combine markdown URLs with linkify URLs + const currentUrls = [...new Set([...markdownUrls, ...linkifyUrls])]; + + // Normalize all URLs for comparison + const normalizedPreviousUrls = previousUrls.map(normalizeUrl); + const normalizedCurrentUrls = currentUrls.map(url => { + const normalized = normalizeUrl(url); + console.log('Normalizing URL:', { original: url, normalized }); + return normalized; + }); + + // Filter out duplicates + const uniqueUrls = currentUrls.filter((url, index) => { + const normalized = normalizedCurrentUrls[index]; + const isDuplicate = normalizedPreviousUrls.some(prevUrl => + normalizeUrl(prevUrl) === normalized + ); + console.log('URL comparison:', { + url, + normalized, + previousUrls: normalizedPreviousUrls, + isDuplicate + }); + return !isDuplicate; + }); + + console.log('Content:', content); + console.log('Found URLs:', uniqueUrls); + console.log('Previous URLs:', previousUrls); + + return uniqueUrls; +} \ No newline at end of file diff --git a/ui/desktop/tailwind.config.ts b/ui/desktop/tailwind.config.ts index 8f12414f7..455b11c5b 100644 --- a/ui/desktop/tailwind.config.ts +++ b/ui/desktop/tailwind.config.ts @@ -40,6 +40,7 @@ export default { 'tool-result-green': '#028E00', 'tool-card': 'rgba(255, 255, 255, 0.80)', + 'link-preview': 'rgba(255, 255, 255, 0.80)', 'user-bubble': 'rgba(85, 95, 231, 0.90)', 'goose-bubble': 'rgba(0, 0, 0, 0.12)' },