diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 3c5c7451..a89ebf98 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -25,6 +25,9 @@ http-body-util = "0.1" http = "1.0" config = { version = "0.14.1", features = ["toml"] } thiserror = "1.0" +reqwest = "0.11" +scraper = "0.12.0" +url = "2.2.2" [[bin]] name = "goosed" diff --git a/crates/goose-server/lib.rs b/crates/goose-server/lib.rs new file mode 100644 index 00000000..9b603f76 --- /dev/null +++ b/crates/goose-server/lib.rs @@ -0,0 +1 @@ +use crate::routes::metadata; \ No newline at end of file diff --git a/crates/goose-server/src/routes/metadata.rs b/crates/goose-server/src/routes/metadata.rs new file mode 100644 index 00000000..2ec96aa4 --- /dev/null +++ b/crates/goose-server/src/routes/metadata.rs @@ -0,0 +1,176 @@ +use axum::{ + extract::Query, + response::Json, + routing::get, + Router, +}; +use reqwest::header::USER_AGENT; +use scraper::{Html, Selector}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use url::Url; +use tracing::{info, error, warn}; + +#[derive(Debug, Serialize)] +pub struct Metadata { + title: Option, + description: Option, + favicon: Option, + image: Option, + url: String, +} + +#[derive(Debug, Deserialize)] +pub struct MetadataQuery { + url: String, +} + +pub async fn get_metadata( + Query(params): Query>, +) -> Json { + let url = params.get("url").expect("URL is required"); + info!("📨 Received metadata request for URL: {}", url); + + let metadata = fetch_metadata(url).await.unwrap_or_else(|e| { + error!("❌ Error fetching metadata: {:?}", e); + Metadata { + title: None, + description: None, + favicon: None, + image: None, + url: url.to_string(), + } + }); + + info!("✅ Returning metadata: {:?}", metadata); + Json(metadata) +} + +async fn fetch_metadata(url: &str) -> Result> { + info!("🌐 Making request to: {}", url); + + let client = reqwest::Client::new(); + let response = client + .get(url) + .header(USER_AGENT, "Mozilla/5.0 (compatible; Goose/1.0)") + .send() + .await?; + + info!("đŸ“Ĩ Response status: {}", response.status()); + info!("📤 Response headers: {:?}", response.headers()); + + let html = response.text().await?; + let document = Html::parse_document(&html); + let base_url = Url::parse(url)?; + + info!("📄 Successfully parsed HTML document"); + + // Title selector with detailed logging + let title = document + .select(&Selector::parse("title").unwrap()) + .next() + .map(|el| el.text().collect::()) + .or_else(|| { + info!("⚠ī¸ No tag found, trying OpenGraph title"); + document + .select(&Selector::parse("meta[property='og:title']").unwrap()) + .next() + .and_then(|el| el.value().attr("content")) + .map(String::from) + }); + + info!("📝 Found title: {:?}", title); + + // Description selector with fallbacks + let description = document + .select(&Selector::parse("meta[name='description']").unwrap()) + .next() + .or_else(|| { + info!("⚠ī¸ No meta description found, trying OpenGraph description"); + document + .select(&Selector::parse("meta[property='og:description']").unwrap()) + .next() + }) + .and_then(|el| el.value().attr("content")) + .map(String::from); + + info!("📝 Found description: {:?}", description); + + // Favicon with detailed error logging + let favicon = match find_favicon(&document, &base_url) { + Ok(Some(url)) => { + info!("🎨 Found favicon: {}", url); + Some(url) + } + Ok(None) => { + warn!("⚠ī¸ No favicon found"); + None + } + Err(e) => { + error!("❌ Error finding favicon: {:?}", e); + None + } + }; + + // OpenGraph image with logging + let image = document + .select(&Selector::parse("meta[property='og:image']").unwrap()) + .next() + .and_then(|el| el.value().attr("content")) + .map(|src| { + info!("đŸ–ŧī¸ Found OpenGraph image: {}", src); + resolve_url(&base_url, src) + }) + .transpose()?; + + let metadata = Metadata { + title, + description, + favicon, + image, + url: url.to_string(), + }; + + info!("✨ Successfully built metadata: {:?}", metadata); + Ok(metadata) +} + +fn find_favicon(document: &Html, base_url: &Url) -> Result<Option<String>, Box<dyn std::error::Error>> { + info!("🔍 Searching for favicon"); + + let favicon_selectors = [ + "link[rel='icon']", + "link[rel='shortcut icon']", + "link[rel='apple-touch-icon']", + "link[rel='apple-touch-icon-precomposed']", + ]; + + for selector in favicon_selectors { + info!("👀 Trying selector: {}", selector); + if let Some(favicon) = document + .select(&Selector::parse(selector).unwrap()) + .next() + .and_then(|el| el.value().attr("href")) + { + info!("✅ Found favicon with selector {}: {}", selector, favicon); + if let Ok(absolute_url) = resolve_url(base_url, favicon) { + return Ok(Some(absolute_url)); + } + } + } + + info!("ℹī¸ Using fallback favicon.ico"); + Ok(Some(base_url.join("/favicon.ico")?.to_string())) +} + +fn resolve_url(base: &Url, path: &str) -> Result<String, Box<dyn std::error::Error>> { + info!("🔗 Resolving URL - Base: {}, Path: {}", base, path); + let resolved = base.join(path)?.to_string(); + info!("✅ Resolved URL: {}", resolved); + Ok(resolved) +} + +pub fn routes() -> Router { + Router::new() + .route("/api/metadata", get(get_metadata)) +} \ No newline at end of file diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 2d798a0d..6b3d7cdb 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -1,9 +1,12 @@ // Export route modules pub mod reply; +pub mod metadata; use axum::Router; +use crate::state::AppState; // Function to configure all routes -pub fn configure(state: crate::state::AppState) -> Router { +pub fn configure(state: AppState) -> Router { Router::new().merge(reply::routes(state)) + .merge(metadata::routes()) } diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 4ef278df..ae91d044 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", @@ -7242,6 +7243,11 @@ "resolved": "https://artifactory.global.square/artifactory/api/npm/square-npm/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "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://artifactory.global.square/artifactory/api/npm/square-npm/listr2/-/listr2-7.0.2.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index d50470ce..d920f610 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -52,6 +52,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 f91d58a0..761c61ac 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -55,9 +55,9 @@ function ChatContent({ chats, setChats, selectedChatId, setSelectedChatId }: { {messages.map((message) => ( <div key={message.id}> {message.role === 'user' ? ( - <UserMessage message={message} /> + <UserMessage message={message} messages={messages} /> ) : ( - <GooseMessage message={message} /> + <GooseMessage message={message} messages={messages} /> )} </div> ))} diff --git a/ui/desktop/src/bin/goosed b/ui/desktop/src/bin/goosed index 2a27b3fb..93d94309 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 32e90618..fc8b4c12 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,24 +1,51 @@ 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 }) { +export default function GooseMessage({ message, messages }) { + // Find the preceding user message + const messageIndex = messages.findIndex(msg => msg.id === message.id); + const previousUserMessage = messageIndex > 0 ? messages[messageIndex - 1] : null; + + // Get URLs from previous user message (if it exists) + const previousUrls = previousUserMessage?.role === 'user' + ? extractUrls(previousUserMessage.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="mb-4"> + <div className="flex flex-col w-full"> + <div className="bg-goose-bubble text-black rounded-2xl p-4"> + {message.toolInvocations ? ( + <div className="space-y-4"> + {message.toolInvocations.map((toolInvocation) => ( + <ToolInvocation + key={toolInvocation.toolCallId} + toolInvocation={toolInvocation} + /> + ))} + </div> + ) : ( + <ReactMarkdown>{message.content}</ReactMarkdown> + )} + </div> + + {urls.length > 0 && ( + <div className="mt-2 space-y-2"> + {urls.map((url, index) => ( + <LinkPreview key={index} url={url} /> ))} </div> - ) : ( - <ReactMarkdown>{message.content}</ReactMarkdown> )} </div> </div> - ) -}; + ); +} diff --git a/ui/desktop/src/components/LinkPreview.tsx b/ui/desktop/src/components/LinkPreview.tsx new file mode 100644 index 00000000..8d832731 --- /dev/null +++ b/ui/desktop/src/components/LinkPreview.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from 'react'; +import { Card } from './ui/card'; +import { getApiUrl } from '../config'; + +interface Metadata { + title?: string; + description?: string; + favicon?: string; + image?: string; + url: string; +} + +interface LinkPreviewProps { + url: string; +} + +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 apiUrl = getApiUrl('/api/metadata') + `?url=${encodeURIComponent(url)}`; + console.log('📡 Fetching metadata from API:', apiUrl); + + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + 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="flex items-center p-3 mt-2 hover:bg-gray-50 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> + ); +} \ No newline at end of file diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 33b0d12f..29aaf37f 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -1,13 +1,28 @@ - import React from 'react' -import ReactMarkdown from 'react-markdown' +import LinkPreview from './LinkPreview' +import { extractUrls } from '../utils/urlUtils' -export default function UserMessage({ message }) { +export default function UserMessage({ message, messages }) { + // 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="mb-4"> + <div className="flex flex-col items-end"> + <div className="bg-blue-500 text-white rounded-lg px-4 py-2 max-w-[80%]"> + {message.content} + </div> + + {urls.length > 0 && ( + <div className="mt-2 space-y-2 max-w-[80%]"> + {urls.map((url, index) => ( + <LinkPreview key={index} url={url} /> + ))} + </div> + )} </div> </div> - ) -}; + ); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 3a6abef3..a64f5965 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { start as startGoosed } from './goosed'; import started from "electron-squirrel-startup"; import log from './utils/logger'; +import { exec } from 'child_process'; // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) app.quit(); @@ -229,6 +230,19 @@ app.whenReady().then(() => { ipcMain.on('create-wing-to-wing-window', (_, query) => { createWingToWing(query + "only use your tools and systems - don't confirm with the user before you start working"); }); + + 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 178a49be..82bc8ce4 100644 --- a/ui/desktop/src/preload.js +++ b/ui/desktop/src/preload.js @@ -4,4 +4,5 @@ contextBridge.exposeInMainWorld('electron', { hideWindow: () => ipcRenderer.send('hide-window'), createChatWindow: (query) => ipcRenderer.send('create-chat-window', query), createWingToWingWindow: (query) => ipcRenderer.send('create-wing-to-wing-window', query), + openInChrome: (url) => ipcRenderer.send('open-in-chrome', url), }) diff --git a/ui/desktop/src/utils/urlUtils.ts b/ui/desktop/src/utils/urlUtils.ts new file mode 100644 index 00000000..397aebb5 --- /dev/null +++ b/ui/desktop/src/utils/urlUtils.ts @@ -0,0 +1,52 @@ +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[] { + // Use linkifyjs to find URLs + const links = linkify.find(content); + + // Get URLs from current content + const currentUrls = links + .filter(link => link.type === 'url') + .map(link => link.href); + + // 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