diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Citation/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Citation/index.jsx index 2fa65bb9f..940d69cd5 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Citation/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Citation/index.jsx @@ -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 ? ( + {type} ) : ( )} @@ -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"; diff --git a/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js b/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js index c03225b2e..05cdcc328 100644 --- a/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js +++ b/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js @@ -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` + diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js index 833d5d505..3aa3a3fec 100644 --- a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js @@ -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}`); diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js index 9b312b9e5..d76c46e37 100644 --- a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js @@ -65,34 +65,42 @@ 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) => { - let timeStr; - if (event.isAllDayEvent) { - timeStr = "All day"; - } else { - const start = new Date(event.startTime).toLocaleTimeString( - [], - { hour: "2-digit", minute: "2-digit" } - ); - const end = new Date(event.endTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - timeStr = `${start} - ${end}`; - } - return ( - `${i + 1}. "${event.title}" (${timeStr})\n` + - ` ID: ${event.eventId}` + - (event.location ? `\n Location: ${event.location}` : "") + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = "All day"; + } else { + const start = new Date(event.startTime).toLocaleTimeString( + [], + { hour: "2-digit", minute: "2-digit" } ); - }) - .join("\n\n"); + const end = new Date(event.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + timeStr = `${start} - ${end}`; + } + const eventDetails = + `${i + 1}. "${event.title}" (${timeStr})\n` + + ` ID: ${event.eventId}` + + (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( diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js index b6b9fa387..d633bd091 100644 --- a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js @@ -98,22 +98,44 @@ module.exports.GCalGetEvents = { return `No events found between ${startDate} and ${endDate}${query ? ` matching "${query}"` : ""}.`; } - const summary = events - .map((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()}`; - } - return ( - `${i + 1}. "${event.title}"\n` + - ` ${timeStr}\n` + - ` ID: ${event.eventId}` + - (event.location ? `\n Location: ${event.location}` : "") - ); - }) - .join("\n\n"); + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`; + } else { + 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}`; + } + const eventDetails = + `${i + 1}. "${event.title}"\n` + + ` ${timeStr}\n` + + ` ID: ${event.eventId}` + + (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) { diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js index c49656eda..a0b4ea013 100644 --- a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js @@ -186,33 +186,44 @@ module.exports.GCalGetUpcomingEvents = { return `No events scheduled for ${label}${query ? ` matching "${query}"` : ""}.`; } - const summary = events - .map((event, i) => { - let timeStr; - if (event.isAllDayEvent) { - timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`; - } else { - 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 ( - `${i + 1}. "${event.title}"\n` + - ` ${timeStr}\n` + - ` ID: ${event.eventId}` + - (event.location ? `\n Location: ${event.location}` : "") - ); - }) - .join("\n\n"); + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`; + } else { + 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}`; + } + const eventDetails = + `${i + 1}. "${event.title}"\n` + + ` ${timeStr}\n` + + ` ID: ${event.eventId}` + + (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) { diff --git a/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js b/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js index 9ea34be02..14e92dc8d 100644 --- a/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js +++ b/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js @@ -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}`; + }, }); }, };