| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- import { untrustedSql } from '@supabase/pg-meta'
- import dynamic from 'next/dynamic'
- import Link from 'next/link'
- import React, {
- isValidElement,
- memo,
- ReactNode,
- useEffect,
- useMemo,
- useRef,
- type ReactElement,
- } from 'react'
- import type { StreamdownProps } from 'streamdown'
- import {
- Button,
- cn,
- Dialog,
- DialogClose,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogSection,
- DialogTitle,
- DialogTrigger,
- } from 'ui'
- import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock'
- import { markdownComponents } from 'ui-patterns/Markdown'
- import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock'
- import { AssistantSnippetProps } from './AIAssistant.types'
- import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
- import { DisplayBlockRenderer } from './DisplayBlockRenderer'
- import { defaultUrlTransform, wrapPlaceholderUrls } from './Message.utils'
- import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
- const Streamdown = dynamic<StreamdownProps>(
- () => import('streamdown').then((mod) => mod.Streamdown),
- { ssr: false }
- )
- // Streamdown splits ordered lists with complex content (e.g. code blocks) into
- // separate <ol> elements. The `start` attribute preserves semantics for screen
- // readers, while `counterReset` is what actually fixes the visible numbering —
- // the prose config (tailwind.config.ts) uses a custom CSS counter named "item"
- // with `listStyleType: 'none'`, so the `start` attribute alone has no visual effect.
- export const OrderedList = memo(({ children, start }: { children?: ReactNode; start?: number }) => (
- <ol
- className="flex flex-col gap-y-4"
- start={start}
- style={start !== undefined ? { counterReset: `item ${start - 1}` } : undefined}
- >
- {children}
- </ol>
- ))
- OrderedList.displayName = 'OrderedList'
- export const ListItem = memo(({ children }: { children?: ReactNode }) => (
- <li className="[&>pre]:mt-2">{children}</li>
- ))
- ListItem.displayName = 'ListItem'
- export const Heading3 = memo(({ children }: { children?: ReactNode }) => (
- <h3 className="underline">{children}</h3>
- ))
- Heading3.displayName = 'Heading3'
- export const InlineCode = memo(
- ({ className, children }: { className?: string; children?: ReactNode }) => (
- <code className={cn('text-xs', className)}>{children}</code>
- )
- )
- InlineCode.displayName = 'InlineCode'
- export const Hyperlink = memo(({ href, children }: { href?: string; children?: ReactNode }) => {
- const isExternalURL = !href?.startsWith('https://supabase.com/dashboard')
- const safeUrl = defaultUrlTransform(href ?? '')
- const isSafeUrl = safeUrl.length > 0
- if (!isSafeUrl) {
- return <span className="text-foreground">{children}</span>
- }
- return (
- <Dialog>
- <DialogTrigger asChild>
- <span
- className={cn(
- 'm-0! text-foreground cursor-pointer transition',
- 'underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground-lighter'
- )}
- >
- {children}
- </span>
- </DialogTrigger>
- <DialogContent size="small">
- <DialogHeader className="border-b">
- <DialogTitle>Verify the link before navigating</DialogTitle>
- </DialogHeader>
- <DialogSection className="flex flex-col">
- <p className="text-sm text-foreground-light">
- This link will take you to the following URL:
- </p>
- <p className="text-sm text-foreground">{safeUrl}</p>
- <p className="text-sm text-foreground-light mt-2">Are you sure you want to head there?</p>
- </DialogSection>
- <DialogFooter>
- <DialogClose asChild>
- <Button type="default" className="opacity-100">
- Cancel
- </Button>
- </DialogClose>
- <DialogClose asChild>
- <Button asChild type="primary" className="opacity-100">
- {isExternalURL ? (
- <a href={safeUrl} target="_blank" rel="noreferrer noopener">
- Head to link
- </a>
- ) : (
- <Link href={safeUrl}>Head to link</Link>
- )}
- </Button>
- </DialogClose>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- })
- Hyperlink.displayName = 'Hyperlink'
- const baseMarkdownComponents = {
- ol: OrderedList,
- li: ListItem,
- h3: Heading3,
- code: InlineCode,
- a: Hyperlink,
- img: ({ src }: React.JSX.IntrinsicElements['img']) => (
- <span className="text-foreground-light font-mono">[Image: {src?.toString()}]</span>
- ),
- }
- export function MessageMarkdown({
- id,
- isLoading,
- readOnly,
- className,
- children,
- }: {
- id: string
- isLoading: boolean
- readOnly?: boolean
- className?: string
- children: ReactNode
- }) {
- const markdownSource = useMemo(() => {
- if (typeof children === 'string') {
- return wrapPlaceholderUrls(children)
- }
- if (Array.isArray(children)) {
- return wrapPlaceholderUrls(
- children.filter((child): child is string => typeof child === 'string').join('')
- )
- }
- return ''
- }, [children])
- const allMarkdownComponents = useMemo(
- () => ({
- ...markdownComponents,
- ...baseMarkdownComponents,
- pre: (props: React.JSX.IntrinsicElements['pre']) => (
- <MarkdownPre id={id} isLoading={isLoading} readOnly={readOnly}>
- {props.children}
- </MarkdownPre>
- ),
- }),
- [id, isLoading, readOnly]
- )
- return (
- <Streamdown className={className} components={allMarkdownComponents}>
- {markdownSource}
- </Streamdown>
- )
- }
- export const MarkdownPre = ({
- children,
- id,
- isLoading: _isLoading,
- readOnly,
- }: {
- children: any
- id: string
- isLoading: boolean
- readOnly?: boolean
- }) => {
- // [Joshen] Using a ref as this data doesn't need to trigger a re-render
- const chartConfig = useRef<ChartConfig>({
- view: 'table',
- type: 'bar',
- xKey: '',
- yKey: '',
- cumulative: false,
- })
- const childArray = Array.isArray(children) ? children : [children]
- const codeElement = childArray.find(
- (child): child is ReactElement<{ className?: string; children: ReactNode }> =>
- isValidElement<{ className?: string; children: ReactNode }>(child)
- )
- const codeProps = codeElement?.props || ({} as { className?: string; children: ReactNode })
- const language = codeProps.className?.replace('language-', '') || 'sql'
- const codeChildren = codeProps.children
- const rawContent = Array.isArray(codeChildren)
- ? codeChildren.map((node) => (typeof node === 'string' ? node : '')).join('')
- : typeof codeChildren === 'string'
- ? codeChildren
- : ''
- const propsMatch = rawContent.match(/(?:--|\/\/)\s*props:\s*(\{[^}]+\})/)
- const snippetProps: AssistantSnippetProps = useMemo(() => {
- try {
- if (propsMatch) {
- return JSON.parse(propsMatch[1])
- }
- } catch {}
- return {}
- }, [propsMatch])
- const { xAxis, yAxis } = snippetProps
- const snippetId = snippetProps.id
- const title = snippetProps.title || (language === 'edge' ? 'Edge Function' : 'SQL Query')
- const isChart = snippetProps.isChart === 'true'
- // Strip props from the content for both SQL and edge functions
- const cleanContent = rawContent.replace(/(?:--|\/\/)\s*props:\s*\{[^}]+\}/, '').trim()
- const toolCallId = String(snippetId ?? id)
- useEffect(() => {
- chartConfig.current = {
- ...chartConfig.current,
- view: isChart ? 'chart' : 'table',
- xKey: xAxis ?? '',
- yKey: yAxis ?? '',
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [snippetProps])
- if (!codeElement) {
- return <pre className="w-auto overflow-x-auto not-prose my-4">{children}</pre>
- }
- return (
- <div className="w-auto overflow-x-hidden not-prose my-4 ">
- {language === 'edge' ? (
- <EdgeFunctionBlock
- label={title}
- code={cleanContent}
- functionName={snippetProps.name || 'my-function'}
- showCode={!readOnly}
- />
- ) : language === 'sql' ? (
- readOnly ? (
- <CollapsibleCodeBlock value={cleanContent} language="sql" hideLineNumbers />
- ) : (
- <DisplayBlockRenderer
- messageId={id}
- toolCallId={toolCallId}
- initialArgs={{
- sql: untrustedSql(cleanContent),
- label: title,
- isWriteQuery: false,
- view: isChart ? 'chart' : 'table',
- xAxis: xAxis ?? '',
- yAxis: yAxis ?? '',
- }}
- onError={() => {}}
- showConfirmFooter={false}
- onChartConfigChange={(config) => {
- chartConfig.current = { ...config }
- }}
- />
- )
- ) : (
- <CodeBlock
- hideLineNumbers
- value={cleanContent}
- language={language as CodeBlockLang}
- className={cn(
- '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',
- '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block [&>code>span]:text-foreground'
- )}
- />
- )}
- </div>
- )
- }
|