WebBlock.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import clsx from 'clsx'
  2. import { MarkdownText, type MarkdownLabels } from './markdown/MarkdownText.tsx'
  3. import css from './WebBlock.module.css'
  4. /**
  5. * One citeable source drawn in a search card: the projection of the contract's
  6. * `WebSource`, with the optional fields kept optional so a provider that
  7. * returned only a URL still renders (its hostname becomes the label).
  8. */
  9. export interface WebSourceView {
  10. /** The source URL; becomes a safe external link when it is http(s). */
  11. url: string
  12. /** The source title; when absent the URL's hostname labels the link. */
  13. title?: string | undefined
  14. /** A short excerpt or summary shown under the link. */
  15. snippet?: string | undefined
  16. /** Publication/crawl timestamp, a provider-supplied string shown under the link. */
  17. publishedAt?: string | undefined
  18. }
  19. /** A `web_search` card: an optional answer over a capped citation list. */
  20. export interface WebSearchBlockProps {
  21. kind: 'search'
  22. /** Localized chrome supplied by the owning render site. */
  23. labels: WebBlockLabels
  24. /** The provider-generated answer, rendered as markdown above the sources. */
  25. answer?: string | undefined
  26. /** The cited sources, in provider order. */
  27. sources: WebSourceView[]
  28. /** True when the tool cut the source list to its result cap. */
  29. truncated: boolean
  30. /** Extra class merged onto the wrapper (callers position; this component draws). */
  31. className?: string | undefined
  32. }
  33. /** A `web_fetch` card: the retrieval summary for one fetched URL. */
  34. export interface WebFetchBlockProps {
  35. kind: 'fetch'
  36. /** Localized chrome supplied by the owning render site. */
  37. labels: WebBlockLabels
  38. /** The final URL after allowed redirects; becomes a safe external link when http(s). */
  39. url: string
  40. /** HTTP status code of the fetched response. */
  41. statusCode: number
  42. /** True when the provider or the output cap cut the fetched content. */
  43. truncated: boolean
  44. /** Extra class merged onto the wrapper (callers position; this component draws). */
  45. className?: string | undefined
  46. }
  47. /** A completed web retrieval card, discriminated by `kind`. */
  48. export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
  49. /** Localized chrome for {@link WebBlock}. */
  50. export interface WebBlockLabels {
  51. noResults: string
  52. sourcesTruncated: string
  53. http: string
  54. contentTruncated: string
  55. markdown: MarkdownLabels
  56. }
  57. /**
  58. * The URL to link to, or undefined when the URL must render as plain text. Only
  59. * http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:`
  60. * URL or an unparseable string never reaches the DOM as an href. This is the
  61. * http(s) subset of the allowlist MarkdownText applies to untrusted links —
  62. * MarkdownText also permits `mailto:`, deliberately excluded here since a
  63. * retrieval URL is never a mail address.
  64. * @param url - the source or fetch URL, from tool result content.
  65. * @returns the href to use, or undefined for plain text.
  66. */
  67. function safeHref(url: string): string | undefined {
  68. try {
  69. const { protocol } = new URL(url)
  70. return protocol === 'http:' || protocol === 'https:' ? url : undefined
  71. } catch {
  72. return undefined
  73. }
  74. }
  75. /**
  76. * The link's visible label: the title when the provider gave one, otherwise the
  77. * URL's hostname, falling back to the raw URL when it does not parse OR parses
  78. * to an empty hostname (a `file:`/`data:`/`javascript:` URL), so a label is
  79. * never blank.
  80. * @param url - the source URL.
  81. * @param title - the provider title, if any.
  82. * @returns the label text.
  83. */
  84. function linkLabel(url: string, title: string | undefined): string {
  85. if (title !== undefined && title !== '') return title
  86. try {
  87. const { hostname } = new URL(url)
  88. return hostname === '' ? url : hostname
  89. } catch {
  90. return url
  91. }
  92. }
  93. /**
  94. * A single URL rendered as a safe external anchor, or as plain text when the
  95. * URL is not an http(s) link.
  96. * @param props.url - the URL to render.
  97. * @param props.label - the visible label.
  98. * @param props.className - class for the anchor or the plain span.
  99. * @returns the anchor or span element.
  100. */
  101. function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) {
  102. const href = safeHref(url)
  103. if (href === undefined) return <span className={className}>{label}</span>
  104. return (
  105. <a className={className} href={href} target="_blank" rel="noopener noreferrer">
  106. {label}
  107. </a>
  108. )
  109. }
  110. /**
  111. * One source row in a search card: the safe link plus its snippet and date. The
  112. * `<li value>` pins the source's 1-based citation index explicitly rather than
  113. * relying on the `<ol>`'s implicit numbering, so a row reads by its real index
  114. * even inside the scroll container.
  115. * @param props.source - the source to render.
  116. * @param props.ordinal - the source's 1-based position in the full list.
  117. * @returns the source list item.
  118. */
  119. function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: number }) {
  120. return (
  121. <li className={css.source} value={ordinal}>
  122. <SafeLink url={source.url} label={linkLabel(source.url, source.title)} className={css.sourceLink} />
  123. {source.snippet !== undefined && source.snippet !== '' && (
  124. <div className={css.snippet}>{source.snippet}</div>
  125. )}
  126. {source.publishedAt !== undefined && source.publishedAt !== '' && (
  127. <div className={css.published}>{source.publishedAt}</div>
  128. )}
  129. </li>
  130. )
  131. }
  132. /**
  133. * The search card body: the answer over the full source list, which scrolls in
  134. * place once it exceeds the `.sources` container height.
  135. * @param props - see {@link WebSearchBlockProps}.
  136. * @returns the search card element.
  137. */
  138. function WebSearchBlock({ answer, sources, truncated, labels, className }: WebSearchBlockProps) {
  139. // A provider may legitimately return no answer and no sources; the chat WebRow
  140. // does not show the raw result content, so without this the user would see an
  141. // empty card. Mirror the backend's `No results found.` render text.
  142. const empty = (answer === undefined || answer === '') && sources.length === 0
  143. return (
  144. <div className={clsx(css.block, className)} data-web="search">
  145. {answer !== undefined && answer !== '' && (
  146. <div className={css.answer}><MarkdownText text={answer} labels={labels.markdown} /></div>
  147. )}
  148. {empty ? (
  149. <div className={css.empty}>{labels.noResults}</div>
  150. ) : (
  151. <ol className={css.sources}>
  152. {sources.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
  153. </ol>
  154. )}
  155. {truncated && <div className={css.truncated}>{labels.sourcesTruncated}</div>}
  156. </div>
  157. )
  158. }
  159. /**
  160. * The fetch card body: the linked URL and its HTTP status.
  161. * @param props - see {@link WebFetchBlockProps}.
  162. * @returns the fetch card element.
  163. */
  164. function WebFetchBlock({ url, statusCode, truncated, labels, className }: WebFetchBlockProps) {
  165. return (
  166. <div className={clsx(css.block, css.fetch, className)} data-web="fetch">
  167. <SafeLink url={url} label={url} className={css.fetchUrl} />
  168. <div className={css.fetchMeta}>
  169. <span className={css.status}>{labels.http} {statusCode}</span>
  170. {truncated && <span className={css.truncated}>{labels.contentTruncated}</span>}
  171. </div>
  172. </div>
  173. )
  174. }
  175. /**
  176. * Render a completed web retrieval as a structured card.
  177. * @param props - see {@link WebBlockProps}; `kind` selects the search or fetch body.
  178. * @returns the web card element.
  179. */
  180. export function WebBlock(props: WebBlockProps) {
  181. return props.kind === 'search' ? <WebSearchBlock {...props} /> : <WebFetchBlock {...props} />
  182. }