Commit c3b82392 by HoMeTown

feat: 处理reference&部分样式优化

parent 758732d4
...@@ -42,11 +42,13 @@ ...@@ -42,11 +42,13 @@
"@heroui/react": "2.6.14", "@heroui/react": "2.6.14",
"@reduxjs/toolkit": "^2.2.7", "@reduxjs/toolkit": "^2.2.7",
"@rsbuild/plugin-image-compress": "^1.1.0", "@rsbuild/plugin-image-compress": "^1.1.0",
"@types/mdast": "^4.0.4",
"ahooks": "^3.8.0", "ahooks": "^3.8.0",
"axios": "^1.7.3", "axios": "^1.7.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"github-markdown-css": "^5.8.0", "github-markdown-css": "^5.8.0",
"mdast": "^3.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-draggable": "^4.4.6", "react-draggable": "^4.4.6",
...@@ -60,7 +62,9 @@ ...@@ -60,7 +62,9 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"tailwind-merge": "^2.4.0" "remark-parse": "^11.0.0",
"tailwind-merge": "^2.4.0",
"unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^2.24.1", "@antfu/eslint-config": "^2.24.1",
...@@ -72,9 +76,11 @@ ...@@ -72,9 +76,11 @@
"@rsbuild/plugin-svgr": "1.0.1-beta.9", "@rsbuild/plugin-svgr": "1.0.1-beta.9",
"@rsbuild/plugin-typed-css-modules": "^1.0.2", "@rsbuild/plugin-typed-css-modules": "^1.0.2",
"@rsdoctor/rspack-plugin": "^0.3.10", "@rsdoctor/rspack-plugin": "^0.3.10",
"@types/hast": "^3.0.4",
"@types/node": "^22.0.2", "@types/node": "^22.0.2",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/unist": "^3.0.3",
"eslint": "^9.8.0", "eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.9", "eslint-plugin-react-refresh": "^0.4.9",
......
...@@ -5,6 +5,7 @@ import rehypeSanitize from 'rehype-sanitize' ...@@ -5,6 +5,7 @@ import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { PhotoProvider, PhotoView } from 'react-photo-view' import { PhotoProvider, PhotoView } from 'react-photo-view'
import { Image } from '@heroui/react' import { Image } from '@heroui/react'
import rehypeReferenceFormat from './plugins/rehypeReference'
interface MarkdownDetailProps { interface MarkdownDetailProps {
children: ReactNode children: ReactNode
...@@ -13,7 +14,7 @@ interface MarkdownDetailProps { ...@@ -13,7 +14,7 @@ interface MarkdownDetailProps {
export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children }) => { export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children }) => {
return ( return (
<ReactMarkdown <ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeSanitize]} rehypePlugins={[rehypeRaw, rehypeReferenceFormat, rehypeSanitize]}
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
className="markdown-body !bg-[#fff] flex flex-col text-[#27353C] text-[15px]" className="markdown-body !bg-[#fff] flex flex-col text-[#27353C] text-[15px]"
components={{ components={{
...@@ -26,15 +27,6 @@ export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children }) ...@@ -26,15 +27,6 @@ export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children })
</PhotoProvider> </PhotoProvider>
) )
}, },
// p(data): JSX.Element {
// return <p className="leading-[24px] break-words w-full" {...data} />
// },
// ul(data): JSX.Element {
// return <ul className="mb-[24px]" {...data} />
// },
// strong(data): JSX.Element {
// return <strong className="text-[#27353C]" {...data} />
// },
}} }}
> >
{children as string} {children as string}
......
import { visit } from 'unist-util-visit'
import type { Element, Node, Parent } from 'hast'
function isElement(node: Node): node is Element {
return node.type === 'element' && 'tagName' in node && typeof node.tagName === 'string'
}
export default function rehypeReferenceFormat() {
return (tree: Node) => {
visit(
tree,
(node: Node, index: number | undefined, parent: Parent | undefined) => {
if (isElement(node) && node.tagName.toLowerCase() === 'reference') {
if (parent && typeof index === 'number') {
parent.children.splice(index, 1)
}
}
},
)
}
}
...@@ -15,7 +15,7 @@ interface ChatAnswerAttachmentProps { ...@@ -15,7 +15,7 @@ interface ChatAnswerAttachmentProps {
fromParser?: boolean fromParser?: boolean
onSubmitQuestion?: (question: string, productCode?: string) => void onSubmitQuestion?: (question: string, productCode?: string) => void
} }
export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ fromParser, answer, isLastAnswer, onSubmitQuestion }) => { export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ answer, isLastAnswer, onSubmitQuestion }) => {
const handleClickBoxItem = (produceName: string, productCode: string) => { const handleClickBoxItem = (produceName: string, productCode: string) => {
if (onSubmitQuestion) { if (onSubmitQuestion) {
onSubmitQuestion(produceName, productCode) onSubmitQuestion(produceName, productCode)
...@@ -27,19 +27,14 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from ...@@ -27,19 +27,14 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from
} }
const handleClickDocLink = async (documentStoreKey: string) => { const handleClickDocLink = async (documentStoreKey: string) => {
if (fromParser) { const res = await fetchGetDocumentLink(`/xiaode/${documentStoreKey}`)
const res = await fetchGetDocumentLink(documentStoreKey)
if (res.data) { if (res.data) {
window.open(res.data.docUrl) window.open(res.data.docUrl)
} }
} }
else {
window.open(documentStoreKey)
}
}
return ( return (
( (
<div className="cardList flex flex-col gap-[20px]"> <>
{answer.cardList && answer.cardList.map((attachment, index) => { {answer.cardList && answer.cardList.map((attachment, index) => {
if (attachment?.type) { if (attachment?.type) {
return ( return (
...@@ -56,15 +51,15 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from ...@@ -56,15 +51,15 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from
</div> </div>
)} )}
{/* 附件:引用文件 */} {/* 附件:引用文件 */}
{attachment.type === 'reference' && attachment.content.docList.length !== 0 && ( {attachment.type === 'reference' && attachment.content && attachment.content?.docList && attachment.content?.docList?.length !== 0 && (
<div> <div>
<p className="text-[14px] text-[#8D9795] mb-[12px]"> <p className="text-[14px] text-[#8D9795] mb-[12px]">
已为您找到 已为您找到
{attachment.content.docList.length} {attachment.content.docList?.length}
篇资料作为参考: 篇资料作为参考:
</p> </p>
<div className="flex flex-col gap-[9px]"> <div className="flex flex-col gap-[9px]">
{ attachment.content.docList.map(doc => ( { attachment.content.docList?.map(doc => (
<Link className="cursor-pointer" onPress={() => handleClickDocLink(doc.documentStoreKey)} size="sm" key={doc.documentStoreKey} isExternal showAnchorIcon underline="hover"> <Link className="cursor-pointer" onPress={() => handleClickDocLink(doc.documentStoreKey)} size="sm" key={doc.documentStoreKey} isExternal showAnchorIcon underline="hover">
{doc.documentAlias} {doc.documentAlias}
</Link> </Link>
...@@ -75,7 +70,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from ...@@ -75,7 +70,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from
)} )}
{/* 附件:选择 box */} {/* 附件:选择 box */}
{ {
attachment.type === 'box' && attachment.content.productList.length !== 0 && ( attachment.type === 'box' && attachment.content && attachment.content?.productList && attachment.content?.productList?.length !== 0 && (
<div> <div>
<div className="mb-[12px]">{attachment.description}</div> <div className="mb-[12px]">{attachment.description}</div>
<ul <ul
...@@ -116,7 +111,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from ...@@ -116,7 +111,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from
} }
return null return null
})} })}
</div> </>
) )
) )
} }
...@@ -95,7 +95,7 @@ export const ChatAnswerOperate: React.FC<ChatAnswerOperateProps> = ({ answer }) ...@@ -95,7 +95,7 @@ export const ChatAnswerOperate: React.FC<ChatAnswerOperateProps> = ({ answer })
} }
} }
return ( return (
<div className="sm:mt-[12px] flex gap-[4px] justify-end"> <div className="flex gap-[4px] justify-end">
{/* 点赞 */} {/* 点赞 */}
<Tooltip color="foreground" content={isLike ? '取消点赞' : '点赞'} className="capitalize"> <Tooltip color="foreground" content={isLike ? '取消点赞' : '点赞'} className="capitalize">
<Button variant="light" isIconOnly aria-label="LikeIcon" onPress={handleLike.run}> <Button variant="light" isIconOnly aria-label="LikeIcon" onPress={handleLike.run}>
......
...@@ -6,7 +6,6 @@ import { formatMarkdown } from './markdownFormatter' ...@@ -6,7 +6,6 @@ import { formatMarkdown } from './markdownFormatter'
import type { Answer } from '@/types/chat' import type { Answer } from '@/types/chat'
import { MarkdownDetail } from '@/components/MarkdownDetail' import { MarkdownDetail } from '@/components/MarkdownDetail'
import { fetchTerminateQuestion } from '@/api/chat' import { fetchTerminateQuestion } from '@/api/chat'
import { fetchGetDocumentLinks } from '@/api/common'
interface ChatAnswerParserProps { interface ChatAnswerParserProps {
answer: Answer answer: Answer
...@@ -38,61 +37,59 @@ function CheckIcon({ ...props }) { ...@@ -38,61 +37,59 @@ function CheckIcon({ ...props }) {
export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer, onTyping, onComplate, answer, isStopTyping, onSubmitQuestion }) => { export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer, onTyping, onComplate, answer, isStopTyping, onSubmitQuestion }) => {
const formatAnswer = formatMarkdown(answer.answer || '') const formatAnswer = formatMarkdown(answer.answer || '')
const [displayedText, setDisplayedText] = useState('') const [displayedText, setDisplayedText] = useState('')
const [currentIndex, setCurrentIndex] = useState(0)
const [isTyping, setIsTyping] = useState(false) const [isTyping, setIsTyping] = useState(false)
const [hideOperate, setHideOperate] = useState(false) const [hideOperate, setHideOperate] = useState(false)
const [isImageAnswer, setIsImageAnswer] = useState(false)
// 提取图片链接 // 提取图片链接
function extractImageSources(htmlString: string): string[] { // function extractImageSources(htmlString: string): string[] {
const imgRegex = /<img[^>]+src="([^">]+)"/gi // const imgRegex = /<img[^>]+src="([^">]+)"/gi
const srcRegex = /src="([^">]+)"/i // const srcRegex = /src="([^">]+)"/i
const matches = htmlString.match(imgRegex) // const matches = htmlString.match(imgRegex)
const sources: string[] = []
if (matches) {
matches.forEach((match) => {
const srcMatch = match.match(srcRegex)
if (srcMatch && srcMatch[1])
sources.push(srcMatch[1])
})
}
return sources // const sources: string[] = []
}
// if (matches) {
// matches.forEach((match) => {
// const srcMatch = match.match(srcRegex)
// if (srcMatch && srcMatch[1])
// sources.push(srcMatch[1])
// })
// }
// return sources
// }
// 替换图片链接 // 替换图片链接
function replaceImageSources(str: string, originalSrcs: string[], newSrcs: string[]): string { // function replaceImageSources(str: string, originalSrcs: string[], newSrcs: string[]): string {
if (originalSrcs.length !== newSrcs.length) // if (originalSrcs.length !== newSrcs.length)
return str // return str
return originalSrcs.reduce((acc, originalSrc, index) => { // return originalSrcs.reduce((acc, originalSrc, index) => {
const newSrc = newSrcs[index] // const newSrc = newSrcs[index]
const regex = new RegExp(originalSrc, 'g') // const regex = new RegExp(originalSrc, 'g')
return acc.replace(regex, newSrc) // return acc.replace(regex, newSrc)
}, str) // }, str)
} // }
// 格式化图片回答 // 格式化图片回答
async function formatImgAnswer(str: string) { // async function formatImgAnswer(str: string) {
const imagesSrc = extractImageSources(str) // const imagesSrc = extractImageSources(str)
const res = await fetchGetDocumentLinks(imagesSrc) // const res = await fetchGetDocumentLinks(imagesSrc)
if (res.data) { // if (res.data) {
const arr = replaceImageSources(str, imagesSrc, res.data.map((item: any) => item.docUrl)) // const arr = replaceImageSources(str, imagesSrc, res.data.map((item: any) => item.docUrl))
return arr // return arr
} // }
else { return replaceImageSources(str, imagesSrc, []) } // else { return replaceImageSources(str, imagesSrc, []) }
} // }
// 处理图片回答 // 处理图片回答
const handleImageAnswer = async () => { // const handleImageAnswer = async () => {
const res = await formatImgAnswer(formatAnswer) // const res = await formatImgAnswer(formatAnswer)
setDisplayedText(res) // setDisplayedText(res)
setIsTyping(false) // setIsTyping(false)
onComplate() // onComplate()
} // }
useEffect(() => { useEffect(() => {
if (isStopTyping) { if (isStopTyping) {
...@@ -102,35 +99,12 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer ...@@ -102,35 +99,12 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
onTyping() onTyping()
setIsTyping(true) setIsTyping(true)
} }
if (currentIndex < formatAnswer.length) { setDisplayedText(formatAnswer)
const nextChar = formatAnswer[currentIndex]
if (nextChar === '<' || isImageAnswer) {
setIsImageAnswer(true)
const timer = setTimeout(() => {
setCurrentIndex(prevIndex => prevIndex + 1)
}, 10) // 调整此值以改变打字速度
return () => clearTimeout(timer)
}
else {
const timer = setTimeout(() => {
setDisplayedText(formatAnswer.slice(0, currentIndex + 1))
setCurrentIndex(prevIndex => prevIndex + 1)
}, 10) // 调整此值以改变打字速度
return () => clearTimeout(timer)
}
}
else {
if (answer.endAnswerFlag) { if (answer.endAnswerFlag) {
if (isImageAnswer) {
handleImageAnswer()
}
else {
setIsTyping(false) setIsTyping(false)
onComplate() onComplate()
} }
} }, [answer])
}
}, [answer, currentIndex])
const handleStopTyping = async () => { const handleStopTyping = async () => {
const res = await fetchTerminateQuestion(answer) const res = await fetchTerminateQuestion(answer)
...@@ -154,7 +128,6 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer ...@@ -154,7 +128,6 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
<div className="answerParser"> <div className="answerParser">
<div className="mb-[8px]"> <div className="mb-[8px]">
{/* <Chip color="primary" className="mb-[12px]">{answer.step?.message}</Chip> */}
{ answer.step?.step === 'answering' && ( { answer.step?.step === 'answering' && (
<Chip color="warning" variant="flat"> <Chip color="warning" variant="flat">
{answer.step?.message} {answer.step?.message}
...@@ -168,11 +141,9 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer ...@@ -168,11 +141,9 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
</div> </div>
{!!displayedText.length && ( {!!displayedText.length && (
<div className={answer.cardList?.length ? 'mb-[20px]' : ''}>
<MarkdownDetail> <MarkdownDetail>
{displayedText} {displayedText}
</MarkdownDetail> </MarkdownDetail>
</div>
)} )}
{!isTyping {!isTyping
&& answer.cardList && answer.cardList
......
...@@ -13,13 +13,11 @@ interface ChatAnswerShowerProps { ...@@ -13,13 +13,11 @@ interface ChatAnswerShowerProps {
export const ChatAnswerShower: React.FC<ChatAnswerShowerProps> = ({ answer, isLastAnswer, onSubmitQuestion }) => { export const ChatAnswerShower: React.FC<ChatAnswerShowerProps> = ({ answer, isLastAnswer, onSubmitQuestion }) => {
const hideOperate = (answer.cardList || []).some(attachment => attachment.type === 'box' || attachment?.type?.includes('card-')) const hideOperate = (answer.cardList || []).some(attachment => attachment.type === 'box' || attachment?.type?.includes('card-'))
return ( return (
<div className="answerShower"> <div>
{answer.answer && ( {answer.answer && (
<div className={answer.cardList?.length ? 'mb-[12px] sm:mb-[20px]' : ''}>
<MarkdownDetail> <MarkdownDetail>
{formatMarkdown(answer.answer || '')} {formatMarkdown(answer.answer || '')}
</MarkdownDetail> </MarkdownDetail>
</div>
)} )}
{answer.cardList && answer.cardList?.length !== 0 && <ChatAnswerAttachment onSubmitQuestion={onSubmitQuestion} isLastAnswer={isLastAnswer} answer={answer} />} {answer.cardList && answer.cardList?.length !== 0 && <ChatAnswerAttachment onSubmitQuestion={onSubmitQuestion} isLastAnswer={isLastAnswer} answer={answer} />}
{/* {} */} {/* {} */}
......
export function formatMarkdown(text: string): string { export function formatMarkdown(text: string): string {
// 首先移除 ♪ 符号之后的所有文本 // 首先移除 ♪ 符号之后的所有文本
let formattedText = text.split('♪')[0].trim() // let formattedText = text.split('♪')[0].trim()
let formattedText = text
// 处理换行 // 处理换行
formattedText = formattedText.replace(/(?<!\n)\n(?!\n)/g, ' \n') formattedText = formattedText.replace(/(?<!\n)\n(?!\n)/g, ' \n')
...@@ -26,5 +27,5 @@ export function formatMarkdown(text: string): string { ...@@ -26,5 +27,5 @@ export function formatMarkdown(text: string): string {
// 处理链接 // 处理链接
formattedText = formattedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1]($2)') formattedText = formattedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1]($2)')
return formattedText return `${formattedText}`
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment