Before cited text1 after
`; + const original = container.innerHTML; + const citation = container.querySelector('#c1') as HTMLElement; + + const clear = highlightCitedRange(1, citation, container, 'yellow'); + + const painted = [...container.querySelectorAlltext2
'; + + expect( + highlightCitedRange(2, container.querySelector('#c2') as HTMLElement, container, 'yellow') + ).toBeUndefined(); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/markdown/citation-highlight.ts b/packages/apollo-react/src/chat/headless/markdown/citation-highlight.ts new file mode 100644 index 0000000000..574bb3a86a --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/citation-highlight.ts @@ -0,0 +1,99 @@ +import { CHAT_CITATION_START } from '../../service'; + +// `citationPlugin` leaves a hidden start marker where each cited range begins; the citation +// element ends it. Several ranges can cite the same id, so take the last start before this one. +const getNearestStartMarker = ( + id: number, + container: Element, + endEl: Element +): HTMLElement | null => { + const candidates = Array.from( + container.querySelectorAll(`.${CHAT_CITATION_START}[data-citation-ids~="${id}"]`) + ) as HTMLElement[]; + let nearest: HTMLElement | null = null; + for (const marker of candidates) { + const rel = marker.compareDocumentPosition(endEl); + const isBefore = + !!(rel & Node.DOCUMENT_POSITION_FOLLOWING) || !!(rel & Node.DOCUMENT_POSITION_CONTAINS); + if (isBefore) { + nearest = marker; + } + } + return nearest; +}; + +/** + * Paints the text a citation supports, from its start marker up to the citation element, inside + * `container` (the message). Elements in between get a background and bare text nodes are + * wrapped in spans; anything inside a `[data-citation-sup="true"]` element is left alone. + * + * @returns a function that removes the highlight, or `undefined` when there is no start marker + */ +export function highlightCitedRange( + id: number, + citation: HTMLElement, + container: Element, + color: string +): (() => void) | undefined { + const startMarker = getNearestStartMarker(id, container, citation); + if (!startMarker) { + return undefined; + } + + const highlightedElements: HTMLElement[] = []; + const wrappedTextSpans: HTMLElement[] = []; + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + ); + + while (walker.nextNode()) { + if (walker.currentNode === startMarker) { + break; + } + } + + while (walker.nextNode()) { + const node = walker.currentNode; + if (node === citation) { + break; + } + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + if (el === startMarker || el.closest('[data-citation-sup="true"]')) { + continue; + } + el.style.transition = 'background-color 0.2s'; + el.style.backgroundColor = color; + highlightedElements.push(el); + } else if (node.nodeType === Node.TEXT_NODE) { + const text = node as Text; + if ( + text.textContent && + text.textContent.trim().length > 0 && + text.parentElement && + !text.parentElement.closest('[data-citation-sup="true"]') + ) { + const span = document.createElement('span'); + span.style.backgroundColor = color; + text.parentElement.insertBefore(span, text); + span.appendChild(text); + wrappedTextSpans.push(span); + } + } + } + + return () => { + for (const el of highlightedElements) { + el.style.backgroundColor = ''; + el.style.transition = ''; + } + for (const span of wrappedTextSpans) { + const parent = span.parentNode; + while (span.firstChild) { + parent?.insertBefore(span.firstChild, span); + } + parent?.removeChild(span); + } + }; +} diff --git a/packages/apollo-react/src/chat/headless/markdown/citations.test.ts b/packages/apollo-react/src/chat/headless/markdown/citations.test.ts new file mode 100644 index 0000000000..4074c805eb --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/citations.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { type AutopilotChatMessage, AutopilotChatRole } from '../../service'; +import { citationFromMarker, getCitationUrl, getGroupCitations } from './citations'; + +describe('citation helpers', () => { + it('builds the pre-hook citation from a marker as Material does', () => { + expect( + citationFromMarker({ id: 1, title: 'Web', url: 'https://a.test', messageId: 'm' }) + ).toEqual({ id: 1, title: 'Web', url: 'https://a.test' }); + expect( + citationFromMarker({ + id: 2, + title: 'Guide', + download_url: 'https://a.test/g.pdf', + page_number: 3, + messageId: 'm', + }) + ).toEqual({ id: 2, title: 'Guide', download_url: 'https://a.test/g.pdf', page_number: 3 }); + }); + + it('opens http(s) and relative URLs, and PDFs at the cited page', () => { + expect(getCitationUrl({ id: 1, title: 'Web', url: 'https://a.test' })).toBe('https://a.test'); + expect(getCitationUrl({ id: 1, title: 'Web', url: 'http://a.test/page' })).toBe( + 'http://a.test/page' + ); + expect( + getCitationUrl({ id: 3, title: 'Local', download_url: '/files/guide.pdf', page_number: 7 }) + ).toBe('/files/guide.pdf#page=7'); + expect( + getCitationUrl({ + id: 2, + title: 'Guide', + download_url: 'https://a.test/g.pdf', + page_number: 3, + }) + ).toBe('https://a.test/g.pdf#page=3'); + }); + + it('refuses URLs that are not http(s) or relative', () => { + for (const url of [ + 'javascript:alert(1)', + ' JavaScript:alert(1)', + 'java\tscript:alert(1)', + 'data:text/html,', + 'blob:https://a.test/1', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + ]) { + expect(getCitationUrl({ id: 1, title: 'Bad', url })).toBeUndefined(); + } + expect( + getCitationUrl({ id: 2, title: 'Bad', download_url: 'javascript:alert(1)', page_number: 1 }) + ).toBeUndefined(); + }); + + it('pins how protocol-relative, backslash and look-alike URLs resolve', () => { + // Protocol-relative and backslash hosts resolve to the page's scheme; an entity-encoded colon + // is a relative path. All stay on http(s), so they may open. + for (const url of ['//host.test/a', '\\\\host.test\\a', 'javascript:alert(1)']) { + expect(getCitationUrl({ id: 1, title: 'Edge', url })).toBe(url); + } + // `https:` with no slashes takes "javascript" as the host and "alert(1)" as the port, which + // does not parse, so it is refused. + expect( + getCitationUrl({ id: 1, title: 'Edge', url: 'https:javascript:alert(1)' }) + ).toBeUndefined(); + }); + + it('lists the citations of a group once each, in order', () => { + const message = (citations: Array<{ id: number; title: string; url: string }>) => + ({ + id: crypto.randomUUID(), + role: AutopilotChatRole.Assistant, + content: '', + created_at: '', + widget: 'apollo-markdown-renderer', + contentParts: [{ text: 'x', citations }], + }) as AutopilotChatMessage; + const a = { id: 1, title: 'A', url: 'https://a.test' }; + const b = { id: 2, title: 'B', url: 'https://b.test' }; + + expect(getGroupCitations([message([a, b]), message([a])])).toEqual([a, b]); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/markdown/citations.ts b/packages/apollo-react/src/chat/headless/markdown/citations.ts new file mode 100644 index 0000000000..8c12dc6831 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/citations.ts @@ -0,0 +1,70 @@ +import type { AutopilotChatMessage, PdfCitation, UrlCitation } from '../../service'; + +/** The props `citationPlugin` gives each `citation` node. */ +export interface CitationMarker { + id: number; + title: string; + url?: string; + page_number?: number; + download_url?: string; + messageId: string; +} + +/** The `citation-click` pre-hook's citation for a marker in the text. */ +export function citationFromMarker({ + id, + title, + url, + download_url, + page_number, +}: CitationMarker): UrlCitation | PdfCitation { + return url + ? { id, title, url } + : { id, title, download_url: download_url as string, page_number: page_number as number }; +} + +const OPENABLE_PROTOCOLS = new Set(['http:', 'https:']); + +/** + * Whether a citation URL may be opened. Citations come from the model or the host's backend, so + * a `javascript:`, `data:`, `blob:` or `vbscript:` URL must not reach `window.open`. The parser + * lowercases the scheme and drops the whitespace and control characters that defeat a substring + * check; relative URLs resolve against the page. + */ +export function isOpenableCitationUrl(href: string): boolean { + try { + return OPENABLE_PROTOCOLS.has(new URL(href, window.location.href).protocol); + } catch { + return false; + } +} + +/** + * Where a citation opens: its URL, or its PDF at the cited page. `undefined` when there is none + * or it is not an http(s) or relative URL. + */ +export function getCitationUrl(citation: UrlCitation | PdfCitation): string | undefined { + let href: string | undefined; + if ('url' in citation && citation.url) { + href = citation.url; + } else if ('download_url' in citation && citation.download_url) { + href = `${citation.download_url}${citation.page_number ? `#page=${citation.page_number}` : ''}`; + } + return href && isOpenableCitationUrl(href) ? href : undefined; +} + +/** Every citation in a group's content parts, first occurrence of each id only. */ +export function getGroupCitations(group: readonly AutopilotChatMessage[]) { + const allCitations = group + .flatMap((message) => message.contentParts?.flatMap((part) => part.citations) ?? []) + .filter((citation): citation is UrlCitation | PdfCitation => citation !== undefined); + + const seenIds = new Set{stripResourceTokens(content)}
; -} diff --git a/packages/apollo-react/src/material/components/ap-chat/components/message/chat-message-content.tsx b/packages/apollo-react/src/material/components/ap-chat/components/message/chat-message-content.tsx index e8efd5822a..8dfdcf02b6 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/message/chat-message-content.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/message/chat-message-content.tsx @@ -124,7 +124,7 @@ const WidgetContainer = React.memo( {isLastInGroup && ( <> {message.role === AutopilotChatRole.Assistant && ( -