Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ldelalande/inline previews #332

Draft
wants to merge 1 commit into
base: v1.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/goose-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/goose-server/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use crate::routes::metadata;
176 changes: 176 additions & 0 deletions crates/goose-server/src/routes/metadata.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
description: Option<String>,
favicon: Option<String>,
image: Option<String>,
url: String,
}

#[derive(Debug, Deserialize)]
pub struct MetadataQuery {
url: String,
}

pub async fn get_metadata(
Query(params): Query<HashMap<String, String>>,
) -> Json<Metadata> {
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<Metadata, Box<dyn std::error::Error>> {
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::<String>())
.or_else(|| {
info!("⚠️ No <title> 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))
}
5 changes: 4 additions & 1 deletion crates/goose-server/src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -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())
}
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 @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions ui/desktop/src/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
))}
Expand Down
Binary file modified ui/desktop/src/bin/goosed
Binary file not shown.
55 changes: 41 additions & 14 deletions ui/desktop/src/components/GooseMessage.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
};
);
}
Loading
Loading