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:
parent
8a81678bb4
commit
e196aefcd3
@ -25,6 +25,11 @@ const ListContainer = ({ children }: Props) => {
|
||||
isUserManuallyScrollingUp.current = false
|
||||
}
|
||||
}
|
||||
|
||||
if (isUserManuallyScrollingUp.current === true) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
prevScrollTop.current = currentScrollTop
|
||||
}, [])
|
||||
|
||||
|
||||
@ -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
2
web/mock/empty-mock.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
const EmptyMock = {}
|
||||
export default EmptyMock
|
||||
@ -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",
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
34
web/utils/codeLanguageExtension.ts
Normal file
34
web/utils/codeLanguageExtension.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user