Commit c3b82392 by HoMeTown

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

parent 758732d4
......@@ -42,11 +42,13 @@
"@heroui/react": "2.6.14",
"@reduxjs/toolkit": "^2.2.7",
"@rsbuild/plugin-image-compress": "^1.1.0",
"@types/mdast": "^4.0.4",
"ahooks": "^3.8.0",
"axios": "^1.7.3",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"github-markdown-css": "^5.8.0",
"mdast": "^3.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.4.6",
......@@ -60,7 +62,9 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.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": {
"@antfu/eslint-config": "^2.24.1",
......@@ -72,9 +76,11 @@
"@rsbuild/plugin-svgr": "1.0.1-beta.9",
"@rsbuild/plugin-typed-css-modules": "^1.0.2",
"@rsdoctor/rspack-plugin": "^0.3.10",
"@types/hast": "^3.0.4",
"@types/node": "^22.0.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/unist": "^3.0.3",
"eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.9",
......
......@@ -5,6 +5,7 @@ import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { PhotoProvider, PhotoView } from 'react-photo-view'
import { Image } from '@heroui/react'
import rehypeReferenceFormat from './plugins/rehypeReference'
interface MarkdownDetailProps {
children: ReactNode
......@@ -13,7 +14,7 @@ interface MarkdownDetailProps {
export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children }) => {
return (
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeSanitize]}
rehypePlugins={[rehypeRaw, rehypeReferenceFormat, rehypeSanitize]}
remarkPlugins={[remarkGfm]}
className="markdown-body !bg-[#fff] flex flex-col text-[#27353C] text-[15px]"
components={{
......@@ -26,15 +27,6 @@ export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children })
</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}
......
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 {
fromParser?: boolean
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) => {
if (onSubmitQuestion) {
onSubmitQuestion(produceName, productCode)
......@@ -27,19 +27,14 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from
}
const handleClickDocLink = async (documentStoreKey: string) => {
if (fromParser) {
const res = await fetchGetDocumentLink(documentStoreKey)
if (res.data) {
window.open(res.data.docUrl)
}
}
else {
window.open(documentStoreKey)
const res = await fetchGetDocumentLink(`/xiaode/${documentStoreKey}`)
if (res.data) {
window.open(res.data.docUrl)
}
}
return (
(
<div className="cardList flex flex-col gap-[20px]">
<>
{answer.cardList && answer.cardList.map((attachment, index) => {
if (attachment?.type) {
return (
......@@ -56,15 +51,15 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from
</div>
)}
{/* 附件:引用文件 */}
{attachment.type === 'reference' && attachment.content.docList.length !== 0 && (
{attachment.type === 'reference' && attachment.content && attachment.content?.docList && attachment.content?.docList?.length !== 0 && (
<div>
<p className="text-[14px] text-[#8D9795] mb-[12px]">
已为您找到
{attachment.content.docList.length}
{attachment.content.docList?.length}
篇资料作为参考:
</p>
<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">
{doc.documentAlias}
</Link>
......@@ -75,7 +70,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from
)}
{/* 附件:选择 box */}
{
attachment.type === 'box' && attachment.content.productList.length !== 0 && (
attachment.type === 'box' && attachment.content && attachment.content?.productList && attachment.content?.productList?.length !== 0 && (
<div>
<div className="mb-[12px]">{attachment.description}</div>
<ul
......@@ -116,7 +111,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ from
}
return null
})}
</div>
</>
)
)
}
......@@ -95,7 +95,7 @@ export const ChatAnswerOperate: React.FC<ChatAnswerOperateProps> = ({ answer })
}
}
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">
<Button variant="light" isIconOnly aria-label="LikeIcon" onPress={handleLike.run}>
......
......@@ -6,7 +6,6 @@ import { formatMarkdown } from './markdownFormatter'
import type { Answer } from '@/types/chat'
import { MarkdownDetail } from '@/components/MarkdownDetail'
import { fetchTerminateQuestion } from '@/api/chat'
import { fetchGetDocumentLinks } from '@/api/common'
interface ChatAnswerParserProps {
answer: Answer
......@@ -38,61 +37,59 @@ function CheckIcon({ ...props }) {
export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer, onTyping, onComplate, answer, isStopTyping, onSubmitQuestion }) => {
const formatAnswer = formatMarkdown(answer.answer || '')
const [displayedText, setDisplayedText] = useState('')
const [currentIndex, setCurrentIndex] = useState(0)
const [isTyping, setIsTyping] = useState(false)
const [hideOperate, setHideOperate] = useState(false)
const [isImageAnswer, setIsImageAnswer] = useState(false)
// 提取图片链接
function extractImageSources(htmlString: string): string[] {
const imgRegex = /<img[^>]+src="([^">]+)"/gi
const srcRegex = /src="([^">]+)"/i
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])
})
}
// function extractImageSources(htmlString: string): string[] {
// const imgRegex = /<img[^>]+src="([^">]+)"/gi
// const srcRegex = /src="([^">]+)"/i
// const matches = htmlString.match(imgRegex)
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 {
if (originalSrcs.length !== newSrcs.length)
return str
return originalSrcs.reduce((acc, originalSrc, index) => {
const newSrc = newSrcs[index]
const regex = new RegExp(originalSrc, 'g')
return acc.replace(regex, newSrc)
}, str)
}
// function replaceImageSources(str: string, originalSrcs: string[], newSrcs: string[]): string {
// if (originalSrcs.length !== newSrcs.length)
// return str
// return originalSrcs.reduce((acc, originalSrc, index) => {
// const newSrc = newSrcs[index]
// const regex = new RegExp(originalSrc, 'g')
// return acc.replace(regex, newSrc)
// }, str)
// }
// 格式化图片回答
async function formatImgAnswer(str: string) {
const imagesSrc = extractImageSources(str)
const res = await fetchGetDocumentLinks(imagesSrc)
if (res.data) {
const arr = replaceImageSources(str, imagesSrc, res.data.map((item: any) => item.docUrl))
return arr
}
// async function formatImgAnswer(str: string) {
// const imagesSrc = extractImageSources(str)
// const res = await fetchGetDocumentLinks(imagesSrc)
// if (res.data) {
// const arr = replaceImageSources(str, imagesSrc, res.data.map((item: any) => item.docUrl))
// return arr
// }
else { return replaceImageSources(str, imagesSrc, []) }
}
// else { return replaceImageSources(str, imagesSrc, []) }
// }
// 处理图片回答
const handleImageAnswer = async () => {
const res = await formatImgAnswer(formatAnswer)
setDisplayedText(res)
setIsTyping(false)
onComplate()
}
// const handleImageAnswer = async () => {
// const res = await formatImgAnswer(formatAnswer)
// setDisplayedText(res)
// setIsTyping(false)
// onComplate()
// }
useEffect(() => {
if (isStopTyping) {
......@@ -102,35 +99,12 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
onTyping()
setIsTyping(true)
}
if (currentIndex < formatAnswer.length) {
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 (isImageAnswer) {
handleImageAnswer()
}
else {
setIsTyping(false)
onComplate()
}
}
setDisplayedText(formatAnswer)
if (answer.endAnswerFlag) {
setIsTyping(false)
onComplate()
}
}, [answer, currentIndex])
}, [answer])
const handleStopTyping = async () => {
const res = await fetchTerminateQuestion(answer)
......@@ -154,7 +128,6 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
<div className="answerParser">
<div className="mb-[8px]">
{/* <Chip color="primary" className="mb-[12px]">{answer.step?.message}</Chip> */}
{ answer.step?.step === 'answering' && (
<Chip color="warning" variant="flat">
{answer.step?.message}
......@@ -168,11 +141,9 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
</div>
{!!displayedText.length && (
<div className={answer.cardList?.length ? 'mb-[20px]' : ''}>
<MarkdownDetail>
{displayedText}
</MarkdownDetail>
</div>
<MarkdownDetail>
{displayedText}
</MarkdownDetail>
)}
{!isTyping
&& answer.cardList
......
......@@ -13,13 +13,11 @@ interface ChatAnswerShowerProps {
export const ChatAnswerShower: React.FC<ChatAnswerShowerProps> = ({ answer, isLastAnswer, onSubmitQuestion }) => {
const hideOperate = (answer.cardList || []).some(attachment => attachment.type === 'box' || attachment?.type?.includes('card-'))
return (
<div className="answerShower">
<div>
{answer.answer && (
<div className={answer.cardList?.length ? 'mb-[12px] sm:mb-[20px]' : ''}>
<MarkdownDetail>
{formatMarkdown(answer.answer || '')}
</MarkdownDetail>
</div>
<MarkdownDetail>
{formatMarkdown(answer.answer || '')}
</MarkdownDetail>
)}
{answer.cardList && answer.cardList?.length !== 0 && <ChatAnswerAttachment onSubmitQuestion={onSubmitQuestion} isLastAnswer={isLastAnswer} answer={answer} />}
{/* {} */}
......
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')
......@@ -26,5 +27,5 @@ export function formatMarkdown(text: string): string {
// 处理链接
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