MessageMarkdown.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import { untrustedSql } from '@supabase/pg-meta'
  2. import dynamic from 'next/dynamic'
  3. import Link from 'next/link'
  4. import React, {
  5. isValidElement,
  6. memo,
  7. ReactNode,
  8. useEffect,
  9. useMemo,
  10. useRef,
  11. type ReactElement,
  12. } from 'react'
  13. import type { StreamdownProps } from 'streamdown'
  14. import {
  15. Button,
  16. cn,
  17. Dialog,
  18. DialogClose,
  19. DialogContent,
  20. DialogFooter,
  21. DialogHeader,
  22. DialogSection,
  23. DialogTitle,
  24. DialogTrigger,
  25. } from 'ui'
  26. import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock'
  27. import { markdownComponents } from 'ui-patterns/Markdown'
  28. import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock'
  29. import { AssistantSnippetProps } from './AIAssistant.types'
  30. import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
  31. import { DisplayBlockRenderer } from './DisplayBlockRenderer'
  32. import { defaultUrlTransform, wrapPlaceholderUrls } from './Message.utils'
  33. import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
  34. const Streamdown = dynamic<StreamdownProps>(
  35. () => import('streamdown').then((mod) => mod.Streamdown),
  36. { ssr: false }
  37. )
  38. // Streamdown splits ordered lists with complex content (e.g. code blocks) into
  39. // separate <ol> elements. The `start` attribute preserves semantics for screen
  40. // readers, while `counterReset` is what actually fixes the visible numbering —
  41. // the prose config (tailwind.config.ts) uses a custom CSS counter named "item"
  42. // with `listStyleType: 'none'`, so the `start` attribute alone has no visual effect.
  43. export const OrderedList = memo(({ children, start }: { children?: ReactNode; start?: number }) => (
  44. <ol
  45. className="flex flex-col gap-y-4"
  46. start={start}
  47. style={start !== undefined ? { counterReset: `item ${start - 1}` } : undefined}
  48. >
  49. {children}
  50. </ol>
  51. ))
  52. OrderedList.displayName = 'OrderedList'
  53. export const ListItem = memo(({ children }: { children?: ReactNode }) => (
  54. <li className="[&>pre]:mt-2">{children}</li>
  55. ))
  56. ListItem.displayName = 'ListItem'
  57. export const Heading3 = memo(({ children }: { children?: ReactNode }) => (
  58. <h3 className="underline">{children}</h3>
  59. ))
  60. Heading3.displayName = 'Heading3'
  61. export const InlineCode = memo(
  62. ({ className, children }: { className?: string; children?: ReactNode }) => (
  63. <code className={cn('text-xs', className)}>{children}</code>
  64. )
  65. )
  66. InlineCode.displayName = 'InlineCode'
  67. export const Hyperlink = memo(({ href, children }: { href?: string; children?: ReactNode }) => {
  68. const isExternalURL = !href?.startsWith('https://supabase.com/dashboard')
  69. const safeUrl = defaultUrlTransform(href ?? '')
  70. const isSafeUrl = safeUrl.length > 0
  71. if (!isSafeUrl) {
  72. return <span className="text-foreground">{children}</span>
  73. }
  74. return (
  75. <Dialog>
  76. <DialogTrigger asChild>
  77. <span
  78. className={cn(
  79. 'm-0! text-foreground cursor-pointer transition',
  80. 'underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground-lighter'
  81. )}
  82. >
  83. {children}
  84. </span>
  85. </DialogTrigger>
  86. <DialogContent size="small">
  87. <DialogHeader className="border-b">
  88. <DialogTitle>Verify the link before navigating</DialogTitle>
  89. </DialogHeader>
  90. <DialogSection className="flex flex-col">
  91. <p className="text-sm text-foreground-light">
  92. This link will take you to the following URL:
  93. </p>
  94. <p className="text-sm text-foreground">{safeUrl}</p>
  95. <p className="text-sm text-foreground-light mt-2">Are you sure you want to head there?</p>
  96. </DialogSection>
  97. <DialogFooter>
  98. <DialogClose asChild>
  99. <Button type="default" className="opacity-100">
  100. Cancel
  101. </Button>
  102. </DialogClose>
  103. <DialogClose asChild>
  104. <Button asChild type="primary" className="opacity-100">
  105. {isExternalURL ? (
  106. <a href={safeUrl} target="_blank" rel="noreferrer noopener">
  107. Head to link
  108. </a>
  109. ) : (
  110. <Link href={safeUrl}>Head to link</Link>
  111. )}
  112. </Button>
  113. </DialogClose>
  114. </DialogFooter>
  115. </DialogContent>
  116. </Dialog>
  117. )
  118. })
  119. Hyperlink.displayName = 'Hyperlink'
  120. const baseMarkdownComponents = {
  121. ol: OrderedList,
  122. li: ListItem,
  123. h3: Heading3,
  124. code: InlineCode,
  125. a: Hyperlink,
  126. img: ({ src }: React.JSX.IntrinsicElements['img']) => (
  127. <span className="text-foreground-light font-mono">[Image: {src?.toString()}]</span>
  128. ),
  129. }
  130. export function MessageMarkdown({
  131. id,
  132. isLoading,
  133. readOnly,
  134. className,
  135. children,
  136. }: {
  137. id: string
  138. isLoading: boolean
  139. readOnly?: boolean
  140. className?: string
  141. children: ReactNode
  142. }) {
  143. const markdownSource = useMemo(() => {
  144. if (typeof children === 'string') {
  145. return wrapPlaceholderUrls(children)
  146. }
  147. if (Array.isArray(children)) {
  148. return wrapPlaceholderUrls(
  149. children.filter((child): child is string => typeof child === 'string').join('')
  150. )
  151. }
  152. return ''
  153. }, [children])
  154. const allMarkdownComponents = useMemo(
  155. () => ({
  156. ...markdownComponents,
  157. ...baseMarkdownComponents,
  158. pre: (props: React.JSX.IntrinsicElements['pre']) => (
  159. <MarkdownPre id={id} isLoading={isLoading} readOnly={readOnly}>
  160. {props.children}
  161. </MarkdownPre>
  162. ),
  163. }),
  164. [id, isLoading, readOnly]
  165. )
  166. return (
  167. <Streamdown className={className} components={allMarkdownComponents}>
  168. {markdownSource}
  169. </Streamdown>
  170. )
  171. }
  172. export const MarkdownPre = ({
  173. children,
  174. id,
  175. isLoading: _isLoading,
  176. readOnly,
  177. }: {
  178. children: any
  179. id: string
  180. isLoading: boolean
  181. readOnly?: boolean
  182. }) => {
  183. // [Joshen] Using a ref as this data doesn't need to trigger a re-render
  184. const chartConfig = useRef<ChartConfig>({
  185. view: 'table',
  186. type: 'bar',
  187. xKey: '',
  188. yKey: '',
  189. cumulative: false,
  190. })
  191. const childArray = Array.isArray(children) ? children : [children]
  192. const codeElement = childArray.find(
  193. (child): child is ReactElement<{ className?: string; children: ReactNode }> =>
  194. isValidElement<{ className?: string; children: ReactNode }>(child)
  195. )
  196. const codeProps = codeElement?.props || ({} as { className?: string; children: ReactNode })
  197. const language = codeProps.className?.replace('language-', '') || 'sql'
  198. const codeChildren = codeProps.children
  199. const rawContent = Array.isArray(codeChildren)
  200. ? codeChildren.map((node) => (typeof node === 'string' ? node : '')).join('')
  201. : typeof codeChildren === 'string'
  202. ? codeChildren
  203. : ''
  204. const propsMatch = rawContent.match(/(?:--|\/\/)\s*props:\s*(\{[^}]+\})/)
  205. const snippetProps: AssistantSnippetProps = useMemo(() => {
  206. try {
  207. if (propsMatch) {
  208. return JSON.parse(propsMatch[1])
  209. }
  210. } catch {}
  211. return {}
  212. }, [propsMatch])
  213. const { xAxis, yAxis } = snippetProps
  214. const snippetId = snippetProps.id
  215. const title = snippetProps.title || (language === 'edge' ? 'Edge Function' : 'SQL Query')
  216. const isChart = snippetProps.isChart === 'true'
  217. // Strip props from the content for both SQL and edge functions
  218. const cleanContent = rawContent.replace(/(?:--|\/\/)\s*props:\s*\{[^}]+\}/, '').trim()
  219. const toolCallId = String(snippetId ?? id)
  220. useEffect(() => {
  221. chartConfig.current = {
  222. ...chartConfig.current,
  223. view: isChart ? 'chart' : 'table',
  224. xKey: xAxis ?? '',
  225. yKey: yAxis ?? '',
  226. }
  227. // eslint-disable-next-line react-hooks/exhaustive-deps
  228. }, [snippetProps])
  229. if (!codeElement) {
  230. return <pre className="w-auto overflow-x-auto not-prose my-4">{children}</pre>
  231. }
  232. return (
  233. <div className="w-auto overflow-x-hidden not-prose my-4 ">
  234. {language === 'edge' ? (
  235. <EdgeFunctionBlock
  236. label={title}
  237. code={cleanContent}
  238. functionName={snippetProps.name || 'my-function'}
  239. showCode={!readOnly}
  240. />
  241. ) : language === 'sql' ? (
  242. readOnly ? (
  243. <CollapsibleCodeBlock value={cleanContent} language="sql" hideLineNumbers />
  244. ) : (
  245. <DisplayBlockRenderer
  246. messageId={id}
  247. toolCallId={toolCallId}
  248. initialArgs={{
  249. sql: untrustedSql(cleanContent),
  250. label: title,
  251. isWriteQuery: false,
  252. view: isChart ? 'chart' : 'table',
  253. xAxis: xAxis ?? '',
  254. yAxis: yAxis ?? '',
  255. }}
  256. onError={() => {}}
  257. showConfirmFooter={false}
  258. onChartConfigChange={(config) => {
  259. chartConfig.current = { ...config }
  260. }}
  261. />
  262. )
  263. ) : (
  264. <CodeBlock
  265. hideLineNumbers
  266. value={cleanContent}
  267. language={language as CodeBlockLang}
  268. className={cn(
  269. 'my-4 max-h-96 max-w-none block border rounded-sm bg-transparent! py-3! px-3.5! prose dark:prose-dark text-foreground',
  270. '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block [&>code>span]:text-foreground'
  271. )}
  272. />
  273. )}
  274. </div>
  275. )
  276. }