Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): add support for custom html tag rendering in markdown #4091

Merged
merged 12 commits into from
Apr 8, 2025
28 changes: 23 additions & 5 deletions ee/tabby-ui/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { FC, memo } from 'react'
import ReactMarkdown, { Options } from 'react-markdown'
import { ElementType, FC, memo } from 'react'
import ReactMarkdown, { Components, Options } from 'react-markdown'

const Markdown = (props: Options) => (
<ReactMarkdown linkTarget="_blank" {...props} />
import {
CUSTOM_HTML_BLOCK_TAGS,
CUSTOM_HTML_INLINE_TAGS
} from '@/lib/constants'

type CustomTag =
| (typeof CUSTOM_HTML_BLOCK_TAGS)[number]
| (typeof CUSTOM_HTML_INLINE_TAGS)[number]

type ExtendedOptions = Omit<Options, 'components'> & {
components?: Components & {
// for custom html tags rendering
[Tag in CustomTag]?: ElementType
}
}

const Markdown = ({ className, ...props }: ExtendedOptions) => (
<div className={className}>
<ReactMarkdown {...props} />
</div>
)

export const MemoizedReactMarkdown: FC<Options> = memo(
export const MemoizedReactMarkdown: FC<ExtendedOptions> = memo(
Markdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Root } from 'hast'
import type { Raw } from 'react-markdown/lib/ast-to-react'
import { visit } from 'unist-util-visit'

function createTagFilterExpression(tagNames: string[]): RegExp {
const escapedTags = tagNames
.map(tag => tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|')
return new RegExp(
`<(/?)(?!/?(${escapedTags}))([^>]*)(?=[\\t\\n\\f\\r />])`,
'gi'
)
}

/**
* Escape HTML tags that are not in tagNames
*/
export function customStripTagsPlugin({ tagNames }: { tagNames: string[] }) {
// const tagFilterExpression = /<(\/?)(?!\/?(think))([^>]*)(?=[\t\n\f\r />])/gi
const tagFilterExpression = createTagFilterExpression(tagNames)

return function (tree: Root) {
visit(tree, 'raw', (node: Raw) => {
node.value = node.value.replace(tagFilterExpression, '&lt;$2$3')
Copy link
Preview

Copilot AI Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The replacement string omits the leading slash captured as group1, which may lead to incorrect escaping of closing tags. Consider changing the replacement to include group1 (e.g., '<$1$3').

Suggested change
node.value = node.value.replace(tagFilterExpression, '&lt;$2$3')
node.value = node.value.replace(tagFilterExpression, '&lt;$1$2$3')

Copilot is powered by AI, so mistakes are possible. Review output carefully before use.

})
return tree
}
}
65 changes: 63 additions & 2 deletions ee/tabby-ui/components/message-markdown/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Fragment, ReactNode, useContext, useMemo, useState } from 'react'
import { compact, isNil } from 'lodash-es'
import { compact, flatten, isNil } from 'lodash-es'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'

Expand All @@ -19,6 +21,7 @@ import {
convertFromFilepath,
convertToFilepath,
encodeMentionPlaceHolder,
formatCustomHTMLBlockTags,
getRangeFromAttachmentCode,
isAttachmentCommitDoc,
resolveFileNameForDisplay
Expand All @@ -41,6 +44,10 @@ import {
SymbolInfo
} from 'tabby-chat-panel/index'

import {
CUSTOM_HTML_BLOCK_TAGS,
CUSTOM_HTML_INLINE_TAGS
} from '@/lib/constants'
import {
MARKDOWN_CITATION_REGEX,
MARKDOWN_COMMAND_REGEX,
Expand All @@ -53,6 +60,7 @@ import { Mention } from '../mention-tag'
import { IconFile, IconFileText } from '../ui/icons'
import { Skeleton } from '../ui/skeleton'
import { CodeElement } from './code'
import { customStripTagsPlugin } from './custom-strip-tags-plugin'
import { DocDetailView } from './doc-detail-view'
import { MessageMarkdownContext } from './markdown-context'

Expand Down Expand Up @@ -272,7 +280,11 @@ export function MessageMarkdown({
}

const encodedMessage = useMemo(() => {
return encodeMentionPlaceHolder(message)
const formattedMessage = formatCustomHTMLBlockTags(
message,
CUSTOM_HTML_BLOCK_TAGS as unknown as string[]
)
return encodeMentionPlaceHolder(formattedMessage)
}, [message])

return (
Expand Down Expand Up @@ -302,7 +314,33 @@ export function MessageMarkdown({
className
)}
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[
[
customStripTagsPlugin,
{
tagNames: flatten([
CUSTOM_HTML_BLOCK_TAGS,
CUSTOM_HTML_INLINE_TAGS
])
}
],
rehypeRaw,
[
rehypeSanitize,
{
...defaultSchema,
tagNames: flatten([
defaultSchema.tagNames,
CUSTOM_HTML_BLOCK_TAGS,
CUSTOM_HTML_INLINE_TAGS
])
}
]
]}
components={{
think: ({ children }) => {
return <ThinkBlock>{children}</ThinkBlock>
},
p({ children }) {
return (
<p className="mb-2 last:mb-0">
Expand Down Expand Up @@ -395,6 +433,29 @@ export function ErrorMessageBlock({
)
}

function ThinkBlock({ children }: { children: ReactNode }): JSX.Element {
return (
<details
open
className={`
my-4 w-full rounded-md border border-gray-300 bg-white
p-3 text-sm text-gray-800
dark:border-zinc-700 dark:bg-zinc-900 dark:text-gray-100
`}
>
<summary
className={`
cursor-pointer list-none font-semibold text-gray-600
outline-none dark:text-gray-300
`}
>
Thinking
</summary>
<div className="mt-2 whitespace-pre-wrap leading-relaxed">{children}</div>
</details>
)
}

function CitationTag({
citationIndex,
showcitation,
Expand Down
7 changes: 5 additions & 2 deletions ee/tabby-ui/lib/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * as regex from './regex'

export const PLACEHOLDER_EMAIL_FORM = '[email protected]'

export const DEFAULT_PAGE_SIZE = 20
Expand All @@ -8,8 +10,9 @@ export const SESSION_STORAGE_KEY = {

export const SLUG_TITLE_MAX_LENGTH = 48

export * as regex from './regex'

export const ERROR_CODE_NOT_FOUND = 'NOT_FOUND'

export const NEWLINE_CHARACTER = '\n'

export const CUSTOM_HTML_BLOCK_TAGS = ['think'] as const
export const CUSTOM_HTML_INLINE_TAGS = [] as const
55 changes: 41 additions & 14 deletions ee/tabby-ui/lib/utils/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,20 +292,6 @@ export function getTitleFromMessages(
const command = value.slice(18, -3)
return `@${command}`
})
.replace(PLACEHOLDER_SYMBOL_REGEX, value => {
try {
const content = JSON.parse(value.slice(9, -2))
return `@${content.label}`
} catch (e) {
return ''
}
})
.replace(PLACEHOLDER_COMMAND_REGEX, value => {
const command = value.slice(19, -3)
return `@${command}`
})
.trim()

.trim()
let title = cleanedLine
if (options?.maxLength) {
Expand Down Expand Up @@ -402,3 +388,44 @@ export async function processingPlaceholder(
}
return processedMessage
}

/**
* Format markdown strings to ensure that closing tags adhere to specified newline rules
* @param inputString
* @returns formatted markdown string
*/
export function formatCustomHTMLBlockTags(
inputString: string,
tagNames: string[]
): string {
const tagPattern = tagNames
.map(tag => tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|')
const regex = new RegExp(`(<(${tagPattern})>.*?</\\2>)`, 'gs')

// Adjust the newline characters for matched closing tags
function adjustNewlines(match: string): string {
const startTagMatch = match.match(new RegExp(`<(${tagPattern})>`))
const endTagMatch = match.match(new RegExp(`</(${tagPattern})>`))

if (!startTagMatch || !endTagMatch) {
return match
}

const startTag = startTagMatch[0]
const endTag = endTagMatch[0]

const content = match
.slice(startTag.length, match.length - endTag.length)
.trim()

// One newline character before and after the start tag
const formattedStart = `\n${startTag}\n`
// Two newline characters before the end tag, and one after
const formattedEnd = `\n\n${endTag}\n`

return `${formattedStart}${content}${formattedEnd}`
}

return inputString.replace(regex, adjustNewlines)
}
3 changes: 3 additions & 0 deletions ee/tabby-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"git-url-parse": "^16.0.0",
"graphql": "^16.8.1",
"graphql-ws": "^5.16.0",
"hast": "^1.0.0",
"humanize-duration": "^3.31.0",
"jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
Expand Down Expand Up @@ -131,6 +132,7 @@
"tabby-chat-panel": "workspace:*",
"tippy.js": "^6.3.7",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"urql": "^4.0.6",
"use-local-storage": "^3.0.0",
"wonka": "^6.3.4",
Expand All @@ -147,6 +149,7 @@
"@types/color": "^3.0.6",
"@types/dompurify": "^3.0.5",
"@types/git-url-parse": "^9.0.3",
"@types/hast": "^3.0.4",
"@types/he": "^1.2.3",
"@types/humanize-duration": "^3.27.4",
"@types/lodash-es": "^4.17.10",
Expand Down
Loading