Better citations for gmail, gcal, and outlook

This commit is contained in:
Timothy Carambat
2026-04-15 10:05:26 -07:00
parent fdc585b832
commit 676f305927
7 changed files with 193 additions and 71 deletions

View File

@@ -12,6 +12,9 @@ import {
LinkSimple,
GitlabLogo,
} from "@phosphor-icons/react";
import GmailLogo from "@/pages/Admin/Agents/GMailSkillPanel/gmail.png";
import GoogleCalendarLogo from "@/pages/Admin/Agents/GoogleCalendarSkillPanel/google-calendar.png";
import OutlookLogo from "@/pages/Admin/Agents/OutlookSkillPanel/outlook.png";
import { toPercentString } from "@/utils/numbers";
import { useTranslation } from "react-i18next";
import { useSourcesSidebar } from "../../SourcesSidebar";
@@ -28,6 +31,14 @@ const CIRCLE_ICONS = {
paperlessNgx: FileText,
};
const CIRCLE_IMAGES = {
gmailThread: GmailLogo,
gmailAttachment: GmailLogo,
googleCalendar: GoogleCalendarLogo,
outlookThread: OutlookLogo,
outlookAttachment: OutlookLogo,
};
/**
* Renders a circle with a source type icon inside, or a favicon if URL is provided.
* @param {"file"|"link"|"youtube"|"github"|"gitlab"|"confluence"|"drupalwiki"|"obsidian"|"paperlessNgx"} props.type
@@ -42,6 +53,7 @@ export function SourceTypeCircle({
url = null,
}) {
const Icon = CIRCLE_ICONS[type] || CIRCLE_ICONS.file;
const customImage = CIRCLE_IMAGES[type];
const [imgError, setImgError] = useState(false);
let faviconUrl = null;
@@ -71,6 +83,13 @@ export function SourceTypeCircle({
className="object-cover"
onError={() => setImgError(true)}
/>
) : customImage ? (
<img
src={customImage}
alt={type}
style={{ width: iconSize, height: iconSize }}
className="object-contain"
/>
) : (
<Icon size={iconSize} weight="bold" className="text-black" />
)}
@@ -262,6 +281,11 @@ const supportedSources = [
"youtube://",
"obsidian://",
"paperless-ngx://",
"gmail-thread://",
"gmail-attachment://",
"google-calendar://",
"outlook-thread://",
"outlook-attachment://",
];
/**
@@ -342,6 +366,30 @@ export function parseChunkSource({ title = "", chunks = [] }) {
icon = "paperlessNgx";
break;
case "gmail-thread://":
text = title;
icon = "gmailThread";
break;
case "gmail-attachment://":
text = title;
icon = "gmailAttachment";
break;
case "google-calendar://":
text = title;
icon = "googleCalendar";
break;
case "outlook-thread://":
text = title;
icon = "outlookThread";
break;
case "outlook-attachment://":
text = title;
icon = "outlookAttachment";
break;
default:
text = url.host + url.pathname;
icon = "link";

View File

@@ -87,6 +87,14 @@ module.exports.GmailReadThread = {
`${this.caller}: Successfully read thread with ${thread.messageCount} messages`
);
this.super.addCitation?.({
id: `gmail-thread-${thread.id}`,
title: thread.subject,
text: messagesFormatted,
chunkSource: `gmail-thread://${thread.permalink}`,
score: null,
});
return (
`Thread: "${thread.subject}"\n` +
`Thread ID: ${thread.id}\n` +

View File

@@ -80,7 +80,7 @@ module.exports.GCalGetEvent = {
.join("\n")
: " (none)";
return (
const eventDetails =
`Event Details:\n` +
`Title: ${event.title}\n` +
`Event ID: ${event.eventId}\n` +
@@ -93,8 +93,16 @@ module.exports.GCalGetEvent = {
`Owned by me: ${event.isOwnedByMe ? "Yes" : "No"}\n` +
`Guests:\n${guestList}\n` +
`Created: ${new Date(event.dateCreated).toLocaleString()}\n` +
`Last Updated: ${new Date(event.lastUpdated).toLocaleString()}`
);
`Last Updated: ${new Date(event.lastUpdated).toLocaleString()}`;
this.super.addCitation?.({
id: `google-calendar-${event.eventId}`,
title: event.title,
text: eventDetails,
chunkSource: `google-calendar://${event.eventId}`,
score: null,
});
return eventDetails;
} catch (e) {
this.super.handlerProps.log(`gcal-get-event error: ${e.message}`);
this.super.introspect(`Error: ${e.message}`);

View File

@@ -65,12 +65,11 @@ module.exports.GCalGetEventsForDay = {
`${this.caller}: Found ${eventCount} event(s) for ${date}`
);
if (eventCount === 0) {
return `No events scheduled for ${date}.`;
}
if (eventCount === 0) return `No events scheduled for ${date}.`;
const summary = events
.map((event, i) => {
const summaries = [];
const citations = [];
events.forEach((event, i) => {
let timeStr;
if (event.isAllDayEvent) {
timeStr = "All day";
@@ -85,14 +84,23 @@ module.exports.GCalGetEventsForDay = {
});
timeStr = `${start} - ${end}`;
}
return (
const eventDetails =
`${i + 1}. "${event.title}" (${timeStr})\n` +
` ID: ${event.eventId}` +
(event.location ? `\n Location: ${event.location}` : "")
);
})
.join("\n\n");
(event.location ? `\n Location: ${event.location}` : "");
summaries.push(eventDetails);
citations.push({
id: `google-calendar-${event.eventId}`,
title: event.title,
text: eventDetails,
chunkSource: `google-calendar://${event.eventId}`,
score: null,
});
});
const summary = summaries.join("\n\n");
citations.forEach((c) => this.super.addCitation?.(c));
return `Events for ${date} (${eventCount} total):\n\n${summary}`;
} catch (e) {
this.super.handlerProps.log(

View File

@@ -98,22 +98,44 @@ module.exports.GCalGetEvents = {
return `No events found between ${startDate} and ${endDate}${query ? ` matching "${query}"` : ""}.`;
}
const summary = events
.map((event, i) => {
const summaries = [];
const citations = [];
events.forEach((event, i) => {
let timeStr;
if (event.isAllDayEvent) {
timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`;
} else {
timeStr = `${new Date(event.startTime).toLocaleString()} - ${new Date(event.endTime).toLocaleString()}`;
const startTime = new Date(event.startTime);
const endTime = new Date(event.endTime);
const dateStr = startTime.toLocaleDateString();
const startTimeStr = startTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const endTimeStr = endTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
timeStr = `${dateStr} ${startTimeStr} - ${endTimeStr}`;
}
return (
const eventDetails =
`${i + 1}. "${event.title}"\n` +
` ${timeStr}\n` +
` ID: ${event.eventId}` +
(event.location ? `\n Location: ${event.location}` : "")
);
})
.join("\n\n");
(event.location ? `\n Location: ${event.location}` : "");
summaries.push(eventDetails);
citations.push({
id: `google-calendar-${event.eventId}`,
title: event.title,
text: eventDetails,
chunkSource: `google-calendar://${event.eventId}`,
score: null,
});
});
const summary = summaries.join("\n\n");
citations.forEach((c) => this.super.addCitation?.(c));
let response = `Found ${totalEvents} event(s)`;
if (returnedEvents < totalEvents) {

View File

@@ -186,8 +186,9 @@ module.exports.GCalGetUpcomingEvents = {
return `No events scheduled for ${label}${query ? ` matching "${query}"` : ""}.`;
}
const summary = events
.map((event, i) => {
const summaries = [];
const citations = [];
events.forEach((event, i) => {
let timeStr;
if (event.isAllDayEvent) {
timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`;
@@ -205,14 +206,24 @@ module.exports.GCalGetUpcomingEvents = {
});
timeStr = `${dateStr} ${startTimeStr} - ${endTimeStr}`;
}
return (
const eventDetails =
`${i + 1}. "${event.title}"\n` +
` ${timeStr}\n` +
` ID: ${event.eventId}` +
(event.location ? `\n Location: ${event.location}` : "")
);
})
.join("\n\n");
(event.location ? `\n Location: ${event.location}` : "");
summaries.push(eventDetails);
citations.push({
id: `google-calendar-${event.eventId}`,
title: event.title,
text: eventDetails,
chunkSource: `google-calendar://${event.eventId}`,
score: null,
});
});
const summary = summaries.join("\n\n");
citations.forEach((c) => this.super.addCitation?.(c));
let response = `Events for ${label}`;
if (returnedEvents < totalEvents) {

View File

@@ -95,6 +95,15 @@ module.exports.OutlookReadThread = {
`${this.caller}: Successfully read thread with ${thread.messageCount} messages`
);
// Report citation for the thread (without attachments)
this.super.addCitation?.({
id: `outlook-thread-${thread.conversationId}`,
title: thread.subject,
text: `Subject: "${thread.subject}"\n\n${messagesFormatted}`,
chunkSource: `outlook-thread://${this._generatePermalink(thread.conversationId)}`,
score: null,
});
return (
`Thread: "${thread.subject}"\n` +
`Conversation ID: ${thread.conversationId}\n` +
@@ -107,6 +116,14 @@ module.exports.OutlookReadThread = {
return handleSkillError(this, "outlook-read-thread", e);
}
},
_generatePermalink: function (conversationId) {
if (!conversationId) return null;
let encodedId = encodeURIComponent(conversationId);
// For outlook, this needs to be specifically encoded
// as the webpage does not respect it like traditional URL encoding
encodedId = encodedId.replace(/-/g, "%2F");
return `https://outlook.live.com/mail/inbox/id/${encodedId}`;
},
});
},
};