Skip to content

Commit

Permalink
[app] Link Previews
Browse files Browse the repository at this point in the history
Co-authored-by: Alex Hancock <[email protected]>
  • Loading branch information
lily-de and alexhancock committed Nov 25, 2024
1 parent 0e1368a commit 5eec775
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 26 deletions.
6 changes: 6 additions & 0 deletions ui/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion ui/desktop/src/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ function ChatContent({
{message.role === 'user' ? (
<UserMessage message={message} />
) : (
<GooseMessage message={message} />
<GooseMessage message={message} messages={messages} />
)}
</div>
))}
Expand Down
Binary file modified ui/desktop/src/bin/goosed
Binary file not shown.
49 changes: 37 additions & 12 deletions ui/desktop/src/components/GooseMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex mb-4">
<div className="bg-goose-bubble w-full text-black rounded-2xl p-4">
{message.toolInvocations ? (
<div className="space-y-4">
{message.toolInvocations.map((toolInvocation) => (
<ToolInvocation
key={toolInvocation.toolCallId}
toolInvocation={toolInvocation}
/>
<div className="flex mb-[16px]">
<div className="flex flex-col">
<div className="bg-goose-bubble text-white rounded-2xl p-4">
{message.toolInvocations ? (
<div className="space-y-4">
{message.toolInvocations.map((toolInvocation) => (
<ToolInvocation
key={toolInvocation.toolCallId}
toolInvocation={toolInvocation}
/>
))}
</div>
) : (
<ReactMarkdown className="prose">{message.content}</ReactMarkdown>
)}
</div>

{urls.length > 0 && (
<div className="mt-2">
{urls.map((url, index) => (
<LinkPreview key={index} url={url} />
))}
</div>
) : (
<ReactMarkdown className="prose">{message.content}</ReactMarkdown>
)}
</div>
</div>
Expand Down
158 changes: 158 additions & 0 deletions ui/desktop/src/components/LinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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<Metadata | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Card
className="max-w-[300px] truncate flex items-center bg-link-preview p-3 transition-colors cursor-pointer"
onClick={() => {
console.log('🔗 Opening URL in Chrome:', url);
window.electron.openInChrome(url);
}}
>
{metadata.favicon && (
<img
src={metadata.favicon}
alt="Site favicon"
className="w-4 h-4 mr-2"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium truncate">{metadata.title || url}</h4>
{metadata.description && (
<p className="text-xs text-gray-500 truncate">{metadata.description}</p>
)}
</div>
{metadata.image && (
<img
src={metadata.image}
alt="Preview"
className="w-16 h-16 object-cover rounded ml-3"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
</Card>
);
}
24 changes: 19 additions & 5 deletions ui/desktop/src/components/UserMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex justify-end mb-[16px]">
<div className="bg-user-bubble text-white rounded-2xl p-4">
<ReactMarkdown>{message.content}</ReactMarkdown>
<div className="flex flex-col">
<div className="bg-user-bubble text-white rounded-2xl p-4">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
{urls.length > 0 && (
<div className="mt-2">
{urls.map((url, index) => (
<LinkPreview key={index} url={url} />
))}
</div>
)}
</div>
</div>
)
};
);
}
40 changes: 34 additions & 6 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -61,8 +62,6 @@ let windowCounter = 0;
const windowMap = new Map<number, BrowserWindow>();

const createChat = (query?: string) => {
const isDev = process.env.NODE_ENV === 'development';

const mainWindow = new BrowserWindow({
titleBarStyle: 'hidden',
trafficLightPosition: { x: 16, y: 18 },
Expand Down Expand Up @@ -203,7 +202,6 @@ app.whenReady().then(async () => {
createTray();
createChat();


// Show launcher input on key combo
globalShortcut.register('Control+Alt+Command+G', createLauncher);

Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions ui/desktop/src/preload.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const { contextBridge, ipcRenderer } = require('electron')


let windowId = null;

// Listen for window ID from main process
Expand All @@ -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),
})

Loading

0 comments on commit 5eec775

Please sign in to comment.