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