import { Fragment, useState, useEffect } from "react"; import { decode as HTMLDecode } from "he"; import truncate from "truncate"; import Modal, { ModalHeader, ModalBody } from "@/components/lib/Modal"; import { FileText, Info, ArrowSquareOut, GithubLogo, YoutubeLogo, LinkSimple, GitlabLogo, GitBranch, } 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 "../../ChatSidebar"; const CIRCLE_ICONS = { file: FileText, link: LinkSimple, youtube: YoutubeLogo, github: GithubLogo, gitlab: GitlabLogo, gitea: GitBranch, confluence: LinkSimple, drupalwiki: FileText, obsidian: FileText, paperlessNgx: FileText, }; const CIRCLE_IMAGES = { gmailThread: GmailLogo, gmailAttachment: GmailLogo, googleCalendar: GoogleCalendarLogo, outlookThread: OutlookLogo, outlookAttachment: OutlookLogo, }; /** * Returns the custom image for a given type, or null if no custom image is available. * @param {string} type * @returns {string|null} */ export function getCustomImage(type) { return CIRCLE_IMAGES[type] ?? null; } /** * Renders a circle with a source type icon inside, or a favicon if URL is provided. * @param {"file"|"link"|"youtube"|"github"|"gitlab"|"gitea"|"confluence"|"drupalwiki"|"obsidian"|"paperlessNgx"} props.type * @param {number} [props.size] - Circle diameter in px * @param {number} [props.iconSize] - Icon size in px * @param {string} [props.url] - Optional URL to fetch favicon from * @param {string} [props.customImage] - Optional custom image to display */ export function SourceTypeCircle({ type = "file", size = 22, iconSize = 12, url = null, customImage = null, }) { const Icon = CIRCLE_ICONS[type] || CIRCLE_ICONS.file; const [imgError, setImgError] = useState(false); let faviconUrl = null; if (type === "link" && url) { try { const hostname = new URL(url).hostname; faviconUrl = `https://www.google.com/s2/favicons?domain=${hostname}&sz=64`; } catch { faviconUrl = null; } } useEffect(() => { setImgError(false); }, [url]); return (
{faviconUrl && !imgError ? ( favicon setImgError(true)} /> ) : customImage ? ( {type} ) : ( )}
); } export function combineLikeSources(sources) { const combined = {}; sources.forEach((source) => { const { id, title, text, chunkSource = "", score = null } = source; if (combined.hasOwnProperty(title)) { combined[title].chunks.push({ id, text, chunkSource, score }); combined[title].references += 1; } else { combined[title] = { title, chunks: [{ id, text, chunkSource, score }], references: 1, }; } }); return Object.values(combined); } export default function Citations({ sources = [] }) { const { sidebarOpen, openSidebar, closeSidebar, sources: currentSources, } = useSourcesSidebar(); const { t } = useTranslation(); if (sources.length === 0) return null; const combined = combineLikeSources(sources); const visibleSources = combined.slice(0, 3); const remainingCount = Math.max(0, combined.length - 3); function handleOpenSourcesSidebar() { if (sidebarOpen && sources === currentSources) { closeSidebar(); } else { openSidebar(sources); } } return ( ); } export function omitChunkHeader(text) { if (!text.includes("")) return text; return text.split("")[1].trim(); } export function CitationDetailModal({ source, onClose }) { const { references, title, chunks } = source; const { isUrl, text: webpageUrl, href: linkTo } = parseChunkSource(source); const { t } = useTranslation(); return ( {webpageUrl} ) : ( truncate(title, 45) ) } subtitle={ references > 1 ? `Referenced ${references} times.` : undefined } onClose={onClose} /> {chunks.map(({ text, score }, idx) => (

{HTMLDecode(omitChunkHeader(text))}

{!!score && (

{toPercentString(score)}{" "} {t("chat_window.similarity_match")}

)}
{idx !== chunks.length - 1 && (
)}
))}
); } const supportedSources = [ "link://", "confluence://", "github://", "gitlab://", "gitea://", "drupalwiki://", "youtube://", "obsidian://", "paperless-ngx://", "gmail-thread://", "gmail-attachment://", "google-calendar://", "outlook-thread://", "outlook-attachment://", ]; /** * Parses the chunk source to get the correct title and/or display text for citations * which contain valid outbound links that can be clicked by the * user when viewing a citation. Optionally allows various icons * to show distinct types of sources. * @param {{title: string, chunks: {text: string, chunkSource: string}[]}} options * @returns {{isUrl: boolean, text: string, href: string, icon: string}} */ export function parseChunkSource({ title = "", chunks = [] }) { const nullResponse = { isUrl: false, text: null, href: null, icon: "file", }; if ( !chunks.length || !supportedSources.some((source) => chunks[0].chunkSource?.startsWith(source) ) ) return nullResponse; try { const sourceID = supportedSources.find((source) => chunks[0].chunkSource?.startsWith(source) ); let url, text, icon; // Try to parse the URL from the chunk source // If it fails, we'll use the title as the text and the link icon // but the document will not be linkable try { url = new URL(chunks[0].chunkSource.split(sourceID)[1]); } catch {} switch (sourceID) { case "link://": text = url.host + url.pathname; icon = "link"; break; case "youtube://": text = title; icon = "youtube"; break; case "github://": text = title; icon = "github"; break; case "gitlab://": text = title; icon = "gitlab"; break; case "gitea://": text = title; icon = "gitea"; break; case "confluence://": text = title; icon = "confluence"; break; case "drupalwiki://": text = title; icon = "drupalwiki"; break; case "obsidian://": text = title; icon = "obsidian"; break; case "paperless-ngx://": text = title; 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"; break; } return { isUrl: !!url, href: url?.toString() ?? "#", text, icon, }; } catch (err) { console.warn(`Unsupported source identifier ${chunks[0].chunkSource}`, err); } return nullResponse; }