diff --git a/web/screens/Thread/ThreadCenterPanel/ChatItem/index.tsx b/web/screens/Thread/ThreadCenterPanel/ChatItem/index.tsx index 92fd6394d..10d408211 100644 --- a/web/screens/Thread/ThreadCenterPanel/ChatItem/index.tsx +++ b/web/screens/Thread/ThreadCenterPanel/ChatItem/index.tsx @@ -10,7 +10,7 @@ import { import ErrorMessage from '@/containers/ErrorMessage' -import SimpleTextMessage from '../SimpleTextMessage' +import MessageContainer from '../TextMessage' type Ref = HTMLDivElement @@ -54,7 +54,7 @@ const ChatItem = forwardRef((message, ref) => { <> {status !== MessageStatus.Error && content?.length > 0 && (
- +
)} {errorMessage && !message.loadModelError && ( diff --git a/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/index.tsx b/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/index.tsx deleted file mode 100644 index d84ae57d7..000000000 --- a/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/index.tsx +++ /dev/null @@ -1,402 +0,0 @@ -/* 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, - ContentType, - MessageStatus, - ThreadMessage, -} from '@janhq/core' - -import { Tooltip } from '@janhq/joi' - -import latex from 'highlight.js/lib/languages/latex' -import { useAtomValue } from 'jotai' -import { FolderOpenIcon } from 'lucide-react' -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' - -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' - -import { openFileTitle } from '@/utils/titleUtils' - -import EditChatInput from '../EditChatInput' -import Icon from '../FileUploadPreview/Icon' -import MessageToolbar from '../MessageToolbar' - -import { RelativeImage } from './RelativeImage' - -import { - editMessageAtom, - getCurrentChatMessagesAtom, - tokenSpeedAtom, -} from '@/helpers/atoms/ChatMessage.atom' -import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' - -const SimpleTextMessage: React.FC = (props) => { - let text = '' - const isUser = props.role === ChatCompletionRole.User - const isSystem = props.role === ChatCompletionRole.System - const editMessage = useAtomValue(editMessageAtom) - const activeThread = useAtomValue(activeThreadAtom) - - if (props.content && props.content.length > 0) { - text = props.content[0]?.text?.value ?? '' - } - - const clipboard = useClipboard({ timeout: 1000 }) - - 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 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 (extractCodeLines(node) === '') { - 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 code-header--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)}` - : '', - }, - ], - }, - { - 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 = ` - - Copied - ` - - setTimeout(() => { - button.innerHTML = ` - - Copy - ` - }, 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 { onViewFile, onViewFileContainer } = usePath() - const tokenSpeed = useAtomValue(tokenSpeedAtom) - const messages = useAtomValue(getCurrentChatMessagesAtom) - - return ( -
-
- {!isUser && !isSystem && } - {isUser && ( -
- - - -
- )} - -
- {isUser - ? props.role - : (activeThread?.assistants[0].assistant_name ?? props.role)} -
-

- {displayDate(props.created)} -

-
- -
- {tokenSpeed && - tokenSpeed.message === props.id && - tokenSpeed.tokenSpeed > 0 && ( -

- Token Speed: {Number(tokenSpeed.tokenSpeed).toFixed(2)}t/s -

- )} -
- -
- <> - {props.content[0]?.type === ContentType.Image && ( -
-
- - onViewFile(`${props.content[0]?.text.annotations[0]}`) - } - /> -
- - -
- } - content={{openFileTitle()}} - /> -
- )} - - {props.content[0]?.type === ContentType.Pdf && ( -
-
- onViewFile(`${props.id}.${props.content[0]?.type}`) - } - /> - - -
- } - content={{openFileTitle()}} - /> - -
-
- {props.content[0].text.name?.replaceAll(/[-._]/g, ' ')} -
-

- {toGibibytes(Number(props.content[0].text.size))} -

-
-
- )} - - {editMessage === props.id ? ( -
- -
- ) : ( -
- - {text} - -
- )} - -
- - ) -} - -export default React.memo(SimpleTextMessage) diff --git a/web/screens/Thread/ThreadCenterPanel/TextMessage/DocMessage.tsx b/web/screens/Thread/ThreadCenterPanel/TextMessage/DocMessage.tsx new file mode 100644 index 000000000..9c0289734 --- /dev/null +++ b/web/screens/Thread/ThreadCenterPanel/TextMessage/DocMessage.tsx @@ -0,0 +1,55 @@ +import { memo } from 'react' + +import { Tooltip } from '@janhq/joi' + +import { FolderOpenIcon } from 'lucide-react' + +import { usePath } from '@/hooks/usePath' + +import { toGibibytes } from '@/utils/converter' +import { openFileTitle } from '@/utils/titleUtils' + +import Icon from '../FileUploadPreview/Icon' + +const DocMessage = ({ + id, + name, + size, +}: { + id: string + name?: string + size?: number +}) => { + const { onViewFile, onViewFileContainer } = usePath() + + return ( +
+
onViewFile(`${id}.pdf`)} + /> + + +
+ } + content={{openFileTitle()}} + /> + +
+
+ {name?.replaceAll(/[-._]/g, ' ')} +
+

+ {toGibibytes(Number(size))} +

+
+
+ ) +} + +export default memo(DocMessage) diff --git a/web/screens/Thread/ThreadCenterPanel/TextMessage/ImageMessage.tsx b/web/screens/Thread/ThreadCenterPanel/TextMessage/ImageMessage.tsx new file mode 100644 index 000000000..117f259c0 --- /dev/null +++ b/web/screens/Thread/ThreadCenterPanel/TextMessage/ImageMessage.tsx @@ -0,0 +1,45 @@ +import { memo, useMemo } from 'react' + +import { ThreadContent } from '@janhq/core' +import { Tooltip } from '@janhq/joi' + +import { FolderOpenIcon } from 'lucide-react' + +import { usePath } from '@/hooks/usePath' + +import { openFileTitle } from '@/utils/titleUtils' + +import { RelativeImage } from '../TextMessage/RelativeImage' + +const ImageMessage = ({ content }: { content: ThreadContent }) => { + const { onViewFile, onViewFileContainer } = usePath() + + const annotation = useMemo( + () => content?.text?.annotations[0] ?? '', + [content] + ) + + return ( +
+
+ onViewFile(annotation)} + /> +
+ + +
+ } + content={{openFileTitle()}} + /> + + ) +} + +export default memo(ImageMessage) diff --git a/web/screens/Thread/ThreadCenterPanel/TextMessage/MarkdownTextMessage.tsx b/web/screens/Thread/ThreadCenterPanel/TextMessage/MarkdownTextMessage.tsx new file mode 100644 index 000000000..2b91271ae --- /dev/null +++ b/web/screens/Thread/ThreadCenterPanel/TextMessage/MarkdownTextMessage.tsx @@ -0,0 +1,222 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable @typescript-eslint/naming-convention */ +import React, { memo } from 'react' + +import Markdown from 'react-markdown' + +import latex from 'highlight.js/lib/languages/latex' +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 { useClipboard } from '@/hooks/useClipboard' + +import { getLanguageFromExtension } from '@/utils/codeLanguageExtension' + +export const MarkdownTextMessage = memo( + ({ text }: { id: string; text: string }) => { + const clipboard = useClipboard({ timeout: 1000 }) + + 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 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 (extractCodeLines(node) === '') { + 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 code-header--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)}` + : '', + }, + ], + }, + { + 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 = ` + + Copied + ` + + setTimeout(() => { + button.innerHTML = ` + + Copy + ` + }, 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 + }) + } + } + return ( + <> + + {text} + + + ) + } +) diff --git a/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/RelativeImage.tsx b/web/screens/Thread/ThreadCenterPanel/TextMessage/RelativeImage.tsx similarity index 94% rename from web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/RelativeImage.tsx rename to web/screens/Thread/ThreadCenterPanel/TextMessage/RelativeImage.tsx index fda4c0755..72d2a9365 100644 --- a/web/screens/Thread/ThreadCenterPanel/SimpleTextMessage/RelativeImage.tsx +++ b/web/screens/Thread/ThreadCenterPanel/TextMessage/RelativeImage.tsx @@ -3,11 +3,9 @@ import { useEffect, useState } from 'react' import { getJanDataFolderPath } from '@janhq/core' export const RelativeImage = ({ - id, src, onClick, }: { - id: string src: string onClick: () => void }) => { @@ -22,7 +20,7 @@ export const RelativeImage = ({ diff --git a/web/screens/Thread/ThreadCenterPanel/TextMessage/index.tsx b/web/screens/Thread/ThreadCenterPanel/TextMessage/index.tsx new file mode 100644 index 000000000..7c4950b51 --- /dev/null +++ b/web/screens/Thread/ThreadCenterPanel/TextMessage/index.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react' + +import { ChatCompletionRole, ContentType, ThreadMessage } from '@janhq/core' + +import { useAtomValue } from 'jotai' +import 'katex/dist/katex.min.css' +import { twMerge } from 'tailwind-merge' + +import LogoMark from '@/containers/Brand/Logo/Mark' + +import { displayDate } from '@/utils/datetime' + +import EditChatInput from '../EditChatInput' +import MessageToolbar from '../MessageToolbar' + +import DocMessage from './DocMessage' +import ImageMessage from './ImageMessage' +import { MarkdownTextMessage } from './MarkdownTextMessage' + +import { + editMessageAtom, + getCurrentChatMessagesAtom, + tokenSpeedAtom, +} from '@/helpers/atoms/ChatMessage.atom' +import { activeThreadAtom } from '@/helpers/atoms/Thread.atom' + +const MessageContainer: React.FC = (props) => { + const isUser = props.role === ChatCompletionRole.User + const isSystem = props.role === ChatCompletionRole.System + const editMessage = useAtomValue(editMessageAtom) + const activeThread = useAtomValue(activeThreadAtom) + + const tokenSpeed = useAtomValue(tokenSpeedAtom) + const messages = useAtomValue(getCurrentChatMessagesAtom) + + const text = useMemo( + () => props.content[0]?.text?.value ?? '', + [props.content] + ) + const messageType = useMemo( + () => props.content[0]?.type ?? '', + [props.content] + ) + + return ( +
+
+ {!isUser && !isSystem && } + {isUser && ( +
+ + + +
+ )} + +
+ {isUser + ? props.role + : (activeThread?.assistants[0].assistant_name ?? props.role)} +
+

+ {displayDate(props.created)} +

+
+ +
+ {tokenSpeed && + tokenSpeed.message === props.id && + tokenSpeed.tokenSpeed > 0 && ( +

+ Token Speed: {Number(tokenSpeed.tokenSpeed).toFixed(2)}t/s +

+ )} +
+ +
+ <> + {messageType === ContentType.Image && ( + + )} + {messageType === ContentType.Pdf && ( + + )} + + {editMessage === props.id ? ( +
+ +
+ ) : ( +
+ +
+ )} + +
+
+ ) +} + +export default React.memo(MessageContainer)