feat: new UI code block and Enable copying of code blocks or plain text mid-stream (#4010)

* feat: improvement ui codeblock

* chore: update ui code block

* chore: finalize ui code block and latex

* chore: fix jest testing and cleanup unused deps
This commit is contained in:
Faisal Amir 2024-11-14 14:46:35 +07:00 committed by GitHub
parent 8a81678bb4
commit e196aefcd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 326 additions and 115 deletions

View File

@ -25,6 +25,11 @@ const ListContainer = ({ children }: Props) => {
isUserManuallyScrollingUp.current = false
}
}
if (isUserManuallyScrollingUp.current === true) {
event.preventDefault()
event.stopPropagation()
}
prevScrollTop.current = currentScrollTop
}, [])

View File

@ -13,6 +13,11 @@ const config = {
moduleNameMapper: {
// ...
'^@/(.*)$': '<rootDir>/$1',
'react-markdown': '<rootDir>/mock/empty-mock.tsx',
'rehype-highlight': '<rootDir>/mock/empty-mock.tsx',
'rehype-katex': '<rootDir>/mock/empty-mock.tsx',
'rehype-raw': '<rootDir>/mock/empty-mock.tsx',
'remark-math': '<rootDir>/mock/empty-mock.tsx',
},
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],

2
web/mock/empty-mock.tsx Normal file
View File

@ -0,0 +1,2 @@
const EmptyMock = {}
export default EmptyMock

View File

@ -14,13 +14,10 @@
"test": "jest"
},
"dependencies": {
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.3.2",
"@janhq/core": "link:./core",
"@janhq/joi": "link:./joi",
"autoprefixer": "10.4.16",
"class-variance-authority": "^0.7.0",
"csstype": "^3.0.10",
"framer-motion": "^10.16.4",
"highlight.js": "^11.9.0",
"jotai": "^2.6.0",
@ -28,8 +25,6 @@
"lodash": "^4.17.21",
"lucide-react": "^0.291.0",
"marked": "^9.1.2",
"marked-highlight": "^2.0.6",
"marked-katex-extension": "^5.0.2",
"next": "14.2.3",
"next-themes": "^0.2.1",
"postcss": "8.4.31",
@ -39,22 +34,25 @@
"react-circular-progressbar": "^2.1.0",
"react-dom": "18.2.0",
"react-dropzone": "14.2.3",
"react-hook-form": "^7.47.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.12.0",
"react-scroll-to-bottom": "^4.2.0",
"react-markdown": "^9.0.1",
"react-toastify": "^9.1.3",
"rehype-highlight": "^7.0.1",
"rehype-highlight-code-lines": "^1.0.4",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-math": "^6.0.0",
"sass": "^1.69.4",
"slate": "latest",
"slate-dom": "0.111.0",
"slate-history": "0.110.3",
"slate-react": "0.110.3",
"tailwind-merge": "^2.0.0",
"tailwindcss": "3.3.5",
"ulidx": "^2.3.0",
"use-debounce": "^10.0.0",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"slate": "latest",
"slate-dom": "0.111.0",
"slate-react": "0.110.3",
"slate-history": "0.110.3"
"uuid": "^9.0.1"
},
"devDependencies": {
"@next/eslint-plugin-next": "^14.0.1",
@ -65,7 +63,7 @@
"@types/react": "18.2.34",
"@types/react-dom": "18.2.14",
"@types/react-icons": "^3.0.0",
"@types/react-scroll-to-bottom": "^4.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.6",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",

View File

@ -3,8 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { MessageStatus } from '@janhq/core'
import hljs from 'highlight.js'
import { useAtom, useAtomValue } from 'jotai'
import { BaseEditor, createEditor, Editor, Transforms } from 'slate'
import { withHistory } from 'slate-history' // Import withHistory
import {
@ -270,7 +270,8 @@ const RichTextEditor = ({
textareaRef.current.style.height = activeSettingInputBox
? '100px'
: '40px'
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
textareaRef.current.style.height =
textareaRef.current.scrollHeight + 2 + 'px'
textareaRef.current?.scrollTo({
top: textareaRef.current.scrollHeight,
behavior: 'instant',

View File

@ -1,4 +1,9 @@
import React, { useEffect, useRef, useState } from 'react'
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/naming-convention */
import React, { useEffect, useState } from 'react'
import Markdown from 'react-markdown'
import {
ChatCompletionRole,
@ -8,14 +13,15 @@ import {
} from '@janhq/core'
import { Tooltip } from '@janhq/joi'
import hljs from 'highlight.js'
import { useAtomValue } from 'jotai'
import { FolderOpenIcon } from 'lucide-react'
import { Marked, Renderer } from 'marked'
import { markedHighlight } from 'marked-highlight'
import markedKatex from 'marked-katex-extension'
import rehypeHighlight from 'rehype-highlight'
import rehypeHighlightCodeLines from 'rehype-highlight-code-lines'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import remarkMath from 'remark-math'
import 'katex/dist/katex.min.css'
import { twMerge } from 'tailwind-merge'
import LogoMark from '@/containers/Brand/Logo/Mark'
@ -23,6 +29,7 @@ import LogoMark from '@/containers/Brand/Logo/Mark'
import { useClipboard } from '@/hooks/useClipboard'
import { usePath } from '@/hooks/usePath'
import { getLanguageFromExtension } from '@/utils/codeLanguageExtension'
import { toGibibytes } from '@/utils/converter'
import { displayDate } from '@/utils/datetime'
@ -53,88 +60,181 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
const clipboard = useClipboard({ timeout: 1000 })
function escapeHtml(html: string): string {
return html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
function extractCodeLines(node: { children: { children: any[] }[] }) {
const codeLines: any[] = []
// Helper function to extract text recursively from children
function getTextFromNode(node: {
type: string
value: any
children: any[]
}): string {
if (node.type === 'text') {
return node.value
} else if (node.children) {
return node.children.map(getTextFromNode).join('')
}
return ''
}
// Traverse each line in the <code> block
node.children[0].children.forEach(
(lineNode: {
type: string
tagName: string
value: any
children: any[]
}) => {
if (lineNode.type === 'element' && lineNode.tagName === 'span') {
const lineContent = getTextFromNode(lineNode)
codeLines.push(lineContent)
}
}
)
// Join the lines with newline characters for proper formatting
return codeLines.join('\n')
}
function wrapCodeBlocksWithoutVisit() {
return (tree: { children: any[] }) => {
tree.children = tree.children.map((node) => {
if (node.tagName === 'pre' && node.children[0]?.tagName === 'code') {
const language = node.children[0].properties.className?.[1]?.replace(
'language-',
''
)
if (!language) return node
return {
type: 'element',
tagName: 'div',
properties: {
className: ['code-block-wrapper'],
},
children: [
{
type: 'element',
tagName: 'div',
properties: {
className: [
'code-block',
'group/item',
'relative',
'my-4',
'overflow-auto',
],
},
children: [
{
type: 'element',
tagName: 'div',
properties: {
className:
'code-header bg-[hsla(var(--app-code-block))] flex justify-between items-center py-2 px-3 border-b border-[hsla(var(--app-border))] rounded-t-lg',
},
children: [
{
type: 'element',
tagName: 'span',
properties: {
className: 'text-xs font-medium text-gray-300',
},
children: [
{
type: 'text',
value: language
? `${getLanguageFromExtension(language)}`
: 'No file name',
},
],
},
{
type: 'element',
tagName: 'button',
properties: {
className:
'copy-button ml-auto flex items-center gap-1 text-xs font-medium text-gray-400 hover:text-gray-600 focus:outline-none',
onClick: (event: Event) => {
clipboard.copy(extractCodeLines(node))
const button = event.currentTarget as HTMLElement
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check pointer-events-none text-green-600"><path d="M20 6 9 17l-5-5"/></svg>
<span>Copied</span>
`
setTimeout(() => {
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy pointer-events-none text-gray-400"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<span>Copy</span>
`
}, 2000)
},
},
children: [
{
type: 'element',
tagName: 'svg',
properties: {
xmlns: 'http://www.w3.org/2000/svg',
width: '16',
height: '16',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
className:
'lucide lucide-copy pointer-events-none text-gray-400',
},
children: [
{
type: 'element',
tagName: 'rect',
properties: {
width: '14',
height: '14',
x: '8',
y: '8',
rx: '2',
ry: '2',
},
children: [],
},
{
type: 'element',
tagName: 'path',
properties: {
d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2',
},
children: [],
},
],
},
{ type: 'text', value: 'Copy' },
],
},
],
},
node,
],
},
],
}
}
return node
})
}
}
const marked: Marked = new Marked(
markedHighlight({
langPrefix: 'hljs',
highlight(code, lang) {
if (lang === undefined || lang === '') {
return hljs.highlight(code, { language: 'plaintext' }).value
}
try {
return hljs.highlight(code, { language: lang }).value
} catch (err) {
return hljs.highlight(code, { language: 'javascript' }).value
}
},
}),
{
renderer: {
html: (html: string) => {
return escapeHtml(html) // Escape any HTML
},
link: (href, title, text) => {
return Renderer.prototype.link
?.apply(this, [href, title, text])
.replace('<a', "<a target='_blank'")
},
code(code, lang) {
return `
<div class="relative code-block group/item overflow-auto">
<button class='text-xs copy-action hidden group-hover/item:block p-2 rounded-lg absolute top-6 right-2'>
${
clipboard.copied
? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check pointer-events-none text-green-600"><path d="M20 6 9 17l-5-5"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy pointer-events-none text-gray-400"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`
}
</button>
<pre class="hljs">
<code class="language-${lang ?? ''}">${code}</code>
</pre>
</div>
`
},
},
}
)
marked.use(markedKatex({ throwOnError: false }))
const { onViewFile, onViewFileContainer } = usePath()
const parsedText = marked.parse(text)
const [tokenCount, setTokenCount] = useState(0)
const [lastTimestamp, setLastTimestamp] = useState<number | undefined>()
const [tokenSpeed, setTokenSpeed] = useState(0)
const messages = useAtomValue(getCurrentChatMessagesAtom)
const codeBlockCopyEvent = useRef((e: Event) => {
const target: HTMLElement = e.target as HTMLElement
if (typeof target.className !== 'string') return null
const isCopyActionClassName = target?.className.includes('copy-action')
if (isCopyActionClassName) {
const content = target?.parentNode?.querySelector('code')?.innerText ?? ''
clipboard.copy(content)
}
})
useEffect(() => {
document.addEventListener('click', codeBlockCopyEvent.current)
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
document.removeEventListener('click', codeBlockCopyEvent.current)
}
}, [])
useEffect(() => {
if (props.status !== MessageStatus.Pending) {
return
@ -285,8 +385,22 @@ const SimpleTextMessage: React.FC<ThreadMessage> = (props) => {
className={twMerge(
'message max-width-[100%] flex flex-col gap-y-2 overflow-auto break-all leading-relaxed '
)}
dangerouslySetInnerHTML={{ __html: parsedText }}
/>
dir="ltr"
>
<Markdown
remarkPlugins={[remarkMath]}
rehypePlugins={[
[rehypeKatex, { throwOnError: false }],
rehypeRaw,
rehypeHighlight,
[rehypeHighlightCodeLines, { showLineNumbers: true }],
wrapCodeBlocksWithoutVisit,
]}
skipHtml={true}
>
{text}
</Markdown>
</div>
)}
</>
</div>

View File

@ -55,22 +55,25 @@
.hljs {
overflow: auto;
display: block;
width: auto;
background: hsla(var(--app-code-block));
color: #f8f8f2;
padding: 16px;
font-size: 14px;
word-wrap: normal;
border-bottom-left-radius: 0.4rem;
border-bottom-right-radius: 0.4rem;
color: #f8f8f2;
}
pre {
background: hsla(var(--app-code-block));
overflow: auto;
padding: 8px 16px;
border-radius: 0.4rem;
margin-top: 1rem;
margin-bottom: 1rem;
white-space: normal;
}
pre > code {
display: block;
text-indent: 0;
white-space: pre;
max-width: 10vw;
font-size: 14px;
overflow: auto;
color: #f8f8f2;
}
.hljs-emphasis {
@ -81,6 +84,14 @@ pre > code {
font-weight: bold;
}
.code-block {
pre {
padding: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
@media screen and (-ms-high-contrast: active) {
.hljs-addition,
.hljs-attribute,
@ -105,3 +116,52 @@ pre > code {
font-weight: bold;
}
}
.code-block-wrapper {
white-space: nowrap;
}
.code-line {
// padding-left: 12px;
padding-right: 12px;
margin-left: -12px;
margin-right: -12px;
border-left: 4px solid transparent;
}
div.code-line:empty {
height: 21.5938px;
}
span.code-line {
// min-width: 100%;
white-space: pre;
display: inline-block;
max-width: 10vw;
}
.code-line.inserted {
background-color: var(--color-inserted-line);
}
.code-line.deleted {
background-color: var(--color-deleted-line);
}
.highlighted-code-line {
background-color: var(--color-highlighted-line);
border-left: 4px solid var(--color-highlighted-line-indicator);
}
.numbered-code-line::before {
content: attr(data-line-number);
margin-left: -8px;
margin-right: 16px;
width: 1rem;
font-size: 12px;
color: var(--color-text-weak);
text-align: right;
display: inline-block;
}

View File

@ -27,11 +27,3 @@
@apply inline-flex flex-col border-s-4 border-[hsla(var(--primary-bg))] bg-[hsla(var(--primary-bg-soft))] px-4 py-2;
}
}
.code-block {
white-space: normal;
}
pre {
max-width: 95vw;
}

View File

@ -0,0 +1,34 @@
// Utility function using switch-case for extension to language mapping
export function getLanguageFromExtension(extension: string): string {
switch (extension.toLowerCase()) {
case 'ts':
case 'tsx':
return 'typescript'
case 'js':
case 'jsx':
return 'javascript'
case 'py':
return 'python'
case 'java':
return 'java'
case 'rb':
return 'ruby'
case 'cs':
return 'csharp'
case 'md':
return 'markdown'
case 'yaml':
case 'yml':
return 'yaml'
case 'sh':
return 'bash'
case 'rs':
return 'rust'
case 'kt':
return 'kotlin'
case 'swift':
return 'swift'
default:
return extension
}
}