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 && (
+ {
+ e.currentTarget.style.display = 'none';
+ }}
+ />
+ )}
+
+
{metadata.title || url}
+ {metadata.description && (
+
{metadata.description}
+ )}
+
+ {metadata.image && (
+ {
+ 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)'
},