Commit a3dec971 by Liu

feat:部分数据分析页面

parent 6bf7f2b8
.scrollView {
// display: flex;
// flex-direction: column;
// flex: 1 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.chatPage {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.content {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 1;
justify-content: flex-start;
overflow: hidden;
}
.scrollable {
flex-direction: column-reverse;
align-items: center;
display: flex;
overflow-x: hidden;
overflow-y: scroll;
position: relative;
width: 100%;
}
.inter {
overflow-anchor: none;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: fit-content;
width: 100%;
padding-bottom: 32px;
}
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
chatPage: string
content: string
inter: string
scrollView: string
scrollable: string
}
declare const cssExports: CssExports
export default cssExports
// src/pages/Chat/components/ChatItem/ChatAnswerAttchment.tsx
import { Button, Link } from '@heroui/react'
import { motion } from 'framer-motion'
import { useState } from 'react'
import type { Answer, Attachment } from '@/types/chat'
import AnswerProDetailIcon from '@/assets/svg/answerProDetail.svg?react'
import CardNavImg from '@/assets/card-nav.png'
import CardCalculation from '@/assets/card-calculation.png'
import CardDetailImg from '@/assets/card-detail.png'
import CardPlansImg from '@/assets/card-book1111.png'
import CardProductCompareImg from '@/assets/card-product2222.png'
import { fetchDownloadFile } from '@/api/common'
import { FilePreviewModal } from '@/components/FilePreviewModal'
interface ChatAnswerAttachmentProps {
answer: Answer
isLastAnswer?: boolean
onSubmitQuestion?: (question: string, productCode?: string) => void
}
export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({
answer,
isLastAnswer,
onSubmitQuestion,
}) => {
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [currentDoc, setCurrentDoc] = useState<any>(null)
const [docUrl, setDocUrl] = useState<string | undefined>(undefined)
const handleClickBoxItem = (produceName: string, productCode: string) => {
if (onSubmitQuestion) {
onSubmitQuestion(produceName, productCode)
}
}
const handleClickCard = (attachment: Attachment) => {
window.open(attachment.content.url)
}
const handleClickDocLink = async (doc: any) => {
try {
const docId = `${doc.knowledgeName}/${doc.documentStoreKey}`
const resBlob: any = await fetchDownloadFile(docId)
const mimeType = blobType(doc.documentAlias) // 修正参数
const blob = new Blob([resBlob.data], { type: mimeType })
// 创建 fileBlob URL
const fileBlobUrl = URL.createObjectURL(blob)
setCurrentDoc(doc)
setDocUrl(fileBlobUrl) // 传递 blob URL
setPreviewModalOpen(true)
}
catch (error) {
console.error('获取文档链接失败:', error)
}
}
function blobType(fileName: string) {
const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase()
switch (ext) {
case '.pdf':
return 'application/pdf'
case '.doc':
return 'application/msword'
case '.docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
case '.xls':
return 'application/vnd.ms-excel'
case '.xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
case '.ppt':
return 'application/vnd.ms-powerpoint'
case '.pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
case '.txt':
return 'text/plain;charset=utf-8'
case '.wps':
return 'application/wps-office.wps'
case '.ett':
return 'application/wps-office.ett'
default:
return 'application/octet-stream'
}
}
const closePreviewModal = () => {
setPreviewModalOpen(false)
setCurrentDoc(null)
setDocUrl(null)
}
return (
<>
<div className="cardList flex flex-col gap-[20px]">
{answer.cardList
&& answer.cardList.map((attachment, index) => {
if (attachment?.type) {
// 使用唯一标识符而不是索引作为 key
const key = `${attachment.type}_${attachment.id || index}`
return (
<div key={key}>
{/* 附件:product-detail */}
{attachment.type === 'product-detail' && (
<div className="bg-[#29B6FD0A] text-[14px] text-primary py-[4px] px-[16px] w-fit flex items-center">
<AnswerProDetailIcon />
<div className="ml-[6px] max-w-full sm:w-full text-nowrap text-ellipsis overflow-hidden">
{attachment.name}
</div>
</div>
)}
{/* 附件:引用文件 */}
{attachment.type === 'reference'
&& attachment.content?.docList
&& attachment.content.docList.length !== 0 && (
<div>
<p className="text-[14px] text-[#8D9795] mb-[12px]">
已为您找到
{attachment.content.docList.length}
篇资料作为参考:
</p>
<div className="flex flex-col gap-[9px]">
{attachment.content.docList.map(doc => (
<Link
className="cursor-pointer"
onPress={() => handleClickDocLink(doc)}
size="sm"
key={doc.documentStoreKey}
isExternal
showAnchorIcon
underline="hover"
>
{doc.documentAlias}
</Link>
))}
</div>
</div>
)}
{/* 附件:选择 box */}
{attachment.type === 'box' && attachment.content.productList.length !== 0 && (
<div>
<div className="mb-[12px]">{attachment.description}</div>
<ul className="flex flex-col gap-[8px]">
{attachment.content.productList.map(product => (
<motion.li key={product.productCode}>
<Button
onPress={() => handleClickBoxItem(product.productName, product.productCode)}
isDisabled={!isLastAnswer}
color="primary"
variant="light"
className="text-left bg-[#F7FCFF] w-full text-[#333] rounded-[23px] data-[hover=true]:bg-[#E5F6FF] data-[hover=true]:text-primary"
>
<div className="w-full text-nowrap text-ellipsis overflow-hidden">
<span className="ml-[8px]">{product.productName}</span>
</div>
</Button>
</motion.li>
))}
</ul>
</div>
)}
{attachment.type?.includes('card-') && (
<div onClick={() => handleClickCard(attachment)}>
{attachment.type === 'card-nav' && (
<img className="w-full max-w-[400px] cursor-pointer" src={CardNavImg} alt="" />
)}
{attachment.type === 'card-detail' && (
<img className="w-full max-w-[400px] cursor-pointer" src={CardDetailImg} alt="" />
)}
{attachment.type === 'card-calculation' && (
<img className="w-full max-w-[400px] cursor-pointer" src={CardCalculation} alt="" />
)}
{attachment.type === 'card-product-compare' && (
<img className="w-full max-w-[400px] cursor-pointer" src={CardProductCompareImg} alt="" />
)}
{attachment.type === 'card-plans' && (
<img className="w-full max-w-[400px] cursor-pointer" src={CardPlansImg} alt="" />
)}
</div>
)}
</div>
)
}
return null
})}
</div>
{/* 文件预览弹窗 */}
<FilePreviewModal isOpen={previewModalOpen} onClose={closePreviewModal} doc={currentDoc} docUrl={docUrl} />
</>
)
}
import { Avatar, Button } from '@heroui/react'
import { useEffect, useState } from 'react'
import { ChatAnswerShower } from './ChatAnswerShower'
import { ChatAnswerParser } from './ChatAnswerParser'
import { ChatAnswerRecommend } from './ChatAnswerRecommend'
import { ChatMaxCount } from './ChatMaxCount'
import type { Answer, ChatRecord } from '@/types/chat'
import AvatarBot from '@/assets/avatarBot.png'
import AIIcon from '@/assets/ai-icon.png'
import { useAppDispatch } from '@/store/hook'
import { setIsAsking } from '@/store/chatSlice'
import SdreamLoading from '@/components/SdreamLoading'
interface ChatAnswerBoxProps {
record: ChatRecord
showIndex: number
isLastAnswer: boolean
index: number
onSubmitQuestion: (question: string, productCode?: string) => void
}
export const ChatAnswerBox: React.FC<ChatAnswerBoxProps> = ({ record, showIndex, isLastAnswer, onSubmitQuestion }) => {
const [isShowRecommend, setIsShowRecommend] = useState(false)
const [recommendUseAnswer, setRecommendUseAnswer] = useState<Answer>()
const [innerRecord, setInnerRecord] = useState<ChatRecord>(record)
const [isTyping, setIsTyping] = useState(false)
const dispatch = useAppDispatch()
const viteOutputObj = import.meta.env.VITE_OUTPUT_OBJ || 'open'
const handleTyping = () => {
setIsTyping(true)
}
const handleComplate = (answer: Answer) => {
setIsShowRecommend(true)
setRecommendUseAnswer(answer)
dispatch(setIsAsking(false))
setIsTyping(false)
}
const handleStopTyping = () => {
const _innerRecord = JSON.parse(JSON.stringify(innerRecord))
_innerRecord.answerList[showIndex].isStopTyping = true
setInnerRecord(_innerRecord)
}
useEffect(() => {
setInnerRecord(record)
}, [record])
return (
<div>
{innerRecord.answerList.map((item, index) => {
return (
index === showIndex && (
<div className="chatItemBotContainer w-full" key={`${item.recordId}-${index}`}>
<div className="flex">
<Avatar
className="sm:mr-[20px] hidden sm:block flex-shrink-0"
src={viteOutputObj === 'inner' ? AIIcon : AvatarBot}
/>
<div
style={{ background: '#F7FAFD' }}
className="rounded-[20px] box-border px-[16px] py-[12px] sm:px-[24px] sm:py-[20px] relative"
>
{item.answer?.length || item.cardList?.length
? (
<div className="content">
{item.isShow && (
<ChatAnswerShower
onSubmitQuestion={onSubmitQuestion}
isLastAnswer={isLastAnswer}
answer={item}
/>
)}
{!item.isShow && !item.isChatMaxCount && (
<ChatAnswerParser
onSubmitQuestion={onSubmitQuestion}
isLastAnswer={isLastAnswer}
isStopTyping={item.isStopTyping}
onTyping={handleTyping}
onComplate={() => handleComplate(item)}
answer={item}
/>
)}
{!item.isShow && item.isChatMaxCount && <ChatMaxCount />}
</div>
)
: (
<SdreamLoading />
)}
</div>
<div className="hidden sm:block w-[65px] flex-shrink-0"></div>
</div>
{isTyping && (
<div className="sm:pl-[62px] mt-[12px]">
<Button onClick={handleStopTyping} color="primary" variant="bordered">
停止生成
</Button>
</div>
)}
{isLastAnswer && !item.isChatMaxCount && isShowRecommend && recommendUseAnswer && (
<ChatAnswerRecommend onSubmitQuestion={onSubmitQuestion} answer={recommendUseAnswer} />
)}
<div className="h-[20px] sm:h-[32px] w-full"></div>
</div>
)
)
})}
</div>
)
}
import { Button, Tooltip } from '@heroui/react'
import { useRef, useState } from 'react'
import { useDebounceFn } from 'ahooks'
import type { Answer } from '@/types/chat'
import LikeIcon from '@/assets/svg/zan.svg?react'
import LikeIconA from '@/assets/svg/zanA.svg?react'
import UnLikeIcon from '@/assets/svg/cai.svg?react'
import UnLikeIconA from '@/assets/svg/caiA.svg?react'
import CopyIcon from '@/assets/svg/copy.svg?react'
import CollectIcon from '@/assets/svg/shouc.svg?react'
import CollectIconA from '@/assets/svg/shoucA.svg?react'
import useToast from '@/hooks/useToast'
import { fetchCancelCollection, fetchSubmitCollection, fetchSubmitFeedback } from '@/api/chat'
import { UnLikeModal } from '@/components/UnLikeModal'
interface ChatAnswerOperateProps {
answer: Answer
}
export const ChatAnswerOperate: React.FC<ChatAnswerOperateProps> = ({ answer }) => {
const showToast = useToast()
const [isCollect, setIsCollect] = useState(answer.collectionFlag)
const [isLike, setIsLike] = useState(answer.feedbackStatus === '01')
const [isUnLike, setIsUnLike] = useState(answer.feedbackStatus === '02')
const [isOpenUnLikeModal, setIsOpenUnLikeOpen] = useState(false)
const isProcessingRef = useRef(false)
const handleCollect = useDebounceFn(async () => {
// 防止重复调用
if (isProcessingRef.current) {
return
}
isProcessingRef.current = true
try {
if (!isCollect) {
setIsCollect(true)
const res = await fetchSubmitCollection(answer.recordId || '')
if (res.ok) {
showToast('收藏成功!', 'success')
}
else {
// 如果请求失败,回滚状态
setIsCollect(false)
}
}
else {
setIsCollect(false)
const res = await fetchCancelCollection(answer.recordId || '')
if (!res.ok) {
// 如果请求失败,回滚状态
setIsCollect(true)
}
}
}
finally {
isProcessingRef.current = false
}
}, { wait: 200 })
const handleLike = useDebounceFn(async () => {
if (!isLike) {
setIsLike(true)
setIsUnLike(false)
await fetchSubmitFeedback({
recordId: answer.recordId,
feedbackStatus: '01',
content: '',
})
showToast('感谢您的反馈', 'success')
}
else {
setIsLike(false)
await fetchSubmitFeedback({
recordId: answer.recordId,
feedbackStatus: '00',
content: '',
})
}
})
const handleUnLike = async () => {
if (!isUnLike) {
setIsOpenUnLikeOpen(true)
}
else {
setIsUnLike(false)
await fetchSubmitFeedback({
recordId: answer.recordId,
feedbackStatus: '00',
content: '',
})
}
}
const handleClose = (isSubmit?: boolean) => {
setIsOpenUnLikeOpen(false)
if (isSubmit) {
setIsLike(false)
setIsUnLike(true)
showToast('感谢您的反馈', 'success')
}
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(answer.answer)
showToast('复制成功!', 'success')
}
catch {
// 如果 clipboard API 不可用,使用传统方法
const textArea = document.createElement('textarea')
textArea.value = answer.answer
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
showToast('复制成功!', 'success')
}
}
return (
<div className="sm:mt-[12px] flex gap-[4px] justify-end">
{/* 点赞 */}
<Tooltip color="foreground" content={isLike ? '取消点赞' : '点赞'} className="capitalize">
<Button variant="light" isIconOnly aria-label="LikeIcon" onPress={handleLike.run}>
{isLike ? <LikeIconA /> : <LikeIcon />}
</Button>
</Tooltip>
{/* 点踩 */}
<Tooltip color="foreground" content={isUnLike ? '取消点踩' : '点踩'} className="capitalize">
<Button variant="light" isIconOnly aria-label="UnLikeIcon" onPress={handleUnLike}>
{isUnLike ? <UnLikeIconA /> : <UnLikeIcon />}
</Button>
</Tooltip>
{/* 复制 */}
<Tooltip color="foreground" content="复制" className="capitalize">
<Button variant="light" isIconOnly aria-label="CopyIcon" onPress={handleCopy}><CopyIcon /></Button>
</Tooltip>
{/* 收藏 */}
<Tooltip color="foreground" content={isCollect ? '取消收藏' : '收藏'} className="capitalize">
<Button variant="light" isIconOnly aria-label="CollectIcon" onPress={handleCollect.run}>
{isCollect ? <CollectIconA /> : <CollectIcon />}
</Button>
</Tooltip>
{/* 重新生成 */}
{/* <Tooltip color="foreground" content="重新生成" className="capitalize">
<Button variant="light" isIconOnly aria-label="ReloadIcon"><ReloadIcon /></Button>
</Tooltip> */}
<UnLikeModal answer={answer} isOpen={isOpenUnLikeModal} onClose={handleClose} />
</div>
)
}
import React, { useEffect, useState } from 'react'
import { Chip } from '@heroui/react'
import { ChatAnswerAttachment } from './ChatAnswerAttchment'
import { ChatAnswerOperate } from './ChatAnswerOperate'
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
isStopTyping: boolean | undefined
isLastAnswer: boolean
onTyping: () => void
onComplate: () => void
onSubmitQuestion: (question: string, productCode?: string) => void
}
function CheckIcon({ ...props }) {
return (
<svg
fill="none"
height={18}
viewBox="0 0 24 24"
width={18}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM16.78 9.7L11.11 15.37C10.97 15.51 10.78 15.59 10.58 15.59C10.38 15.59 10.19 15.51 10.05 15.37L7.22 12.54C6.93 12.25 6.93 11.77 7.22 11.48C7.51 11.19 7.99 11.19 8.28 11.48L10.58 13.78L15.72 8.64C16.01 8.35 16.49 8.35 16.78 8.64C17.07 8.93 17.07 9.4 16.78 9.7Z"
fill="currentColor"
/>
</svg>
)
}
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)
const [hasProcessedCardList, setHasProcessedCardList] = useState(false) // 添加标记,避免重复处理cardList
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])
})
}
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)
}
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, []) }
}
const handleImageAnswer = async () => {
const res = await formatImgAnswer(formatAnswer)
setDisplayedText(res)
setIsTyping(false)
onComplate()
}
const handleCardListUrls = () => {
// 如果已经处理过cardList,直接返回,避免重复警告
if (hasProcessedCardList) {
return
}
if (answer.cardList && answer.cardList.length > 0) {
const cardsWithUrl = answer.cardList[0].url || ''
// eslint-disable-next-line no-console
console.log('准备跳转的URL:', cardsWithUrl)
if (cardsWithUrl) {
window.open(cardsWithUrl, '_blank')
}
else {
console.warn('cardList中的URL为空')
}
}
else {
console.warn('cardList为空或不存在')
}
setHasProcessedCardList(true) // 标记已处理
}
useEffect(() => {
if (isStopTyping) {
return
}
if (!isTyping) {
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()
}
// 流式输出结束时检查 cardList 中的 URL
handleCardListUrls()
}
}
}, [answer, currentIndex])
const handleStopTyping = async () => {
const res = await fetchTerminateQuestion(answer)
if (res.ok) {
setIsTyping(false)
onComplate()
}
}
useEffect(() => {
if (isStopTyping) {
handleStopTyping()
}
}, [isStopTyping])
useEffect(() => {
setHideOperate((answer.cardList || []).some(attachment => attachment?.type === 'box' || attachment?.type?.includes('card-')))
}, [answer.cardList])
return (
<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}
</Chip>
)}
{answer.step?.step === 'finished' && (
<Chip color="primary" variant="flat" startContent={<CheckIcon />}>
{answer.step?.message}
</Chip>
)}
</div>
{!!displayedText.length && (
<div style={{ background: '#F7FAFD' }} className={answer.cardList?.length ? 'mb-[20px]' : ''}>
<MarkdownDetail>
{displayedText}
</MarkdownDetail>
</div>
)}
{!isTyping
&& answer.cardList
&& answer.cardList?.length !== 0
&& <ChatAnswerAttachment fromParser isLastAnswer={isLastAnswer} onSubmitQuestion={onSubmitQuestion} answer={answer} />}
{!isTyping && !hideOperate && <ChatAnswerOperate answer={answer} />}
{!isTyping && <div className="flex text-[10px] right-[16px] text-[#d0d1d2] bottom-[4px]">AI生成</div>}
</div>
)
}
import { useEffect, useState } from 'react'
import { Button, Skeleton } from '@heroui/react'
import type { Answer } from '@/types/chat'
import { fetchQueryRecommendQuestion } from '@/api/chat'
import SendIcon from '@/assets/svg/sendBlack.svg?react'
interface ChatAnswerRecommendProps {
answer: Answer
onSubmitQuestion: (question: string) => void
}
export const ChatAnswerRecommend: React.FC<ChatAnswerRecommendProps> = ({ answer, onSubmitQuestion }) => {
let isGet = false
const [questionList, setQuestionList] = useState<string[]>([])
const [loading, setLoading] = useState<boolean>(false)
const getAnswerRecommend = async () => {
setLoading(true)
// 从 sessionStorage 中获取 toolId
const toolId = typeof window !== 'undefined' ? sessionStorage.getItem('currentToolId') : null
const res = await fetchQueryRecommendQuestion(
answer.conversationId || '',
answer.recordId || '',
toolId || undefined,
)
if (res.ok) {
setQuestionList(res.data.questionList)
}
setLoading(false)
}
useEffect(() => {
if (!isGet) {
isGet = true
if (typeof window === 'undefined') {
return
}
const shouldSkipFetch = sessionStorage.getItem('showToolQuestion') === 'true'
if (shouldSkipFetch) {
return // skip calling recommend API when tool question mode is enabled
}
getAnswerRecommend()
}
}, [])
return (
<div className="sm:pl-[62px] mt-[12px] flex flex-col">
{!loading && questionList.length !== 0 && questionList.length > 0 && (
<div className="flex flex-col gap-[8px]">
{
questionList.map(item => (
<Button onPress={() => onSubmitQuestion(item)} key={item} color="primary" variant="light" className="text-left bg-[#fff] w-fit max-w-full text-[#333] rounded-[8px] data-[hover=true]:bg-[#F6F6F8] data-[hover=true]:text-[#333]">
<div className="w-full sm:w-full text-nowrap text-ellipsis overflow-hidden">
{item}
</div>
<SendIcon />
</Button>
))
}
</div>
)}
{
loading && questionList && questionList.length === 0 && (
<div className="flex flex-col gap-[8px]">
<Skeleton className="w-2/3 sm:w-[300px] rounded-lg">
<div className="h-[40px] w-full rounded-lg bg-[#fff]"></div>
</Skeleton>
<Skeleton className="w-3/4 sm:w-[300px] rounded-lg">
<div className="h-[40px] w-full rounded-lg bg-[#fff]"></div>
</Skeleton>
</div>
)
}
</div>
)
}
import { formatMarkdown } from './markdownFormatter'
import { ChatAnswerAttachment } from './ChatAnswerAttchment'
import { ChatAnswerOperate } from './ChatAnswerOperate'
import type { Answer } from '@/types/chat'
import { MarkdownDetail } from '@/components/MarkdownDetail'
interface ChatAnswerShowerProps {
answer: Answer
isLastAnswer: boolean
onSubmitQuestion: (question: string) => void
}
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">
{answer.answer && (
<div className={answer.cardList?.length ? 'mb-[12px] sm:mb-[20px]' : ''}>
<MarkdownDetail>
{formatMarkdown(answer.answer || '')}
</MarkdownDetail>
</div>
)}
{answer.cardList && answer.cardList?.length !== 0 && <ChatAnswerAttachment onSubmitQuestion={onSubmitQuestion} isLastAnswer={isLastAnswer} answer={answer} />}
{/* {} */}
{!hideOperate && <ChatAnswerOperate answer={answer} />}
<div className="flex text-[10px] right-[16px] text-[#d0d1d2] bottom-[4px]">AI生成</div>
</div>
)
}
import React, { useEffect, useState } from 'react'
interface ChatAnswerTypeItProps {
content: string
typingSpeed?: number
}
const ChatAnswerTypeIt: React.FC<ChatAnswerTypeItProps> = ({ content, typingSpeed = 50 }) => {
const [displayedText, setDisplayedText] = useState('')
const [currentIndex, setCurrentIndex] = useState(0)
useEffect(() => {
if (currentIndex < content.length) {
const timer = setTimeout(() => {
setDisplayedText(prev => prev + content[currentIndex])
setCurrentIndex(prev => prev + 1)
}, typingSpeed)
return () => clearTimeout(timer)
}
}, [content, currentIndex, typingSpeed])
return <span>{displayedText}</span>
}
export default ChatAnswerTypeIt
import { Avatar } from '@heroui/react'
import AvatarUser from '@/assets/avatarUser.png'
import type { ChatRecord } from '@/types/chat'
interface ChatItemUserProps {
record: ChatRecord
}
export const ChatItemUser: React.FC<ChatItemUserProps> = ({ record }) => {
return (
<div className="chatItemUser">
<div className="text-[#b9b9bb] text-[13px] text-center w-full">{record.qaTime}</div>
<div className="flex justify-end">
<div className="sm:block w-[65px] flex-shrink-0"></div>
<div className="text-[15px] bg-[#BFE9FE] rounded-[20px] box-border text-[#000000] px-[16px] py-[12px] sm:px-[24px] sm:py-[20px]">{record.question}</div>
<Avatar className="hidden sm:block ml-[20px] flex-shrink-0" src={AvatarUser} />
</div>
<div className="h-[20px] sm:h-[32px] w-full"></div>
</div>
)
}
import { Alert } from '@heroui/alert'
import { Button } from '@heroui/react'
import { useAppDispatch } from '@/store/hook'
import { createConversation } from '@/store/conversationSlice'
export const ChatMaxCount: React.FC = () => {
const dispatch = useAppDispatch()
return (
<div className="flex items-center justify-center">
<Alert
color="warning"
description="超过最大轮数上限"
title="提示"
variant="faded"
endContent={(
<Button
color="warning"
size="sm"
variant="flat"
className="ml-[12px]"
onPress={() => {
dispatch(createConversation({
conversationData: {},
shouldNavigate: true,
shouldSendQuestion: '',
}))
}}
>
新建对话
</Button>
)}
>
</Alert>
</div>
)
}
export { ChatItem } from './ChatItem'
export function formatMarkdown(text: string): string {
// 首先移除 ♪ 符号之后的所有文本
let formattedText = text.split('♪')[0].trim()
// 处理换行
formattedText = formattedText.replace(/(?<!\n)\n(?!\n)/g, ' \n')
// 处理代码块
formattedText = formattedText.replace(/```(\w+)?\n([\s\S]*?)\n```/g, (match, language, code) => {
return `\n\`\`\`${language || ''}\n${code.trim()}\n\`\`\`\n`
})
// 处理行内代码
formattedText = formattedText.replace(/`([^`\n]+)`/g, '`$1`')
// 处理列表
formattedText = formattedText.replace(/^( *)[-*+] /gm, '$1- ')
// 处理标题
formattedText = formattedText.replace(/^(#{1,6}) /gm, '$1 ')
// 处理粗体和斜体
formattedText = formattedText.replace(/(\*\*|__)(.*?)\1/g, '**$2**')
formattedText = formattedText.replace(/(\*|_)(.*?)\1/g, '*$2*')
// 处理链接
formattedText = formattedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1]($2)')
return formattedText
}
export const ChatMaskBar: React.FC = () => {
return (
<div className="absolute top-[64px] w-full h-[32px] z-[2] sm:top-[110px] bg-gradient-to-b from-[hsl(var(--sdream-background))] to-transparent"></div>
)
}
import { useNavigate } from 'react-router-dom'
import TextLogo from '@/assets/svg/textLogo.svg?react'
import GradualSpacing from '@/components/GradualSpacing'
export const ChatSlogan: React.FC = () => {
const navigate = useNavigate()
return (
<div className="w-full">
<div className="h-[64px] mx-auto sm:h-[112px] flex flex-col justify-center">
<div className="flex items-center cursor-pointer" onClick={() => navigate('/')}>
<TextLogo className="w-[46px] sm:w-[70px]" />
<GradualSpacing text="晓得解惑,让沟通更智能" className="ml-[8px] text-[14px] sm:text-[16px] text-[#333] font-medium" />
</div>
<h3 className="hidden sm:block text-[12px] text-[#333] font-light">知晓市场脉搏,引领行业潮流,晓得AI助手全方位为您保驾护航</h3>
</div>
</div>
)
}
export { ChatSlogan } from './ChatSlogan'
import { Avatar } from '@heroui/react'
import { motion } from 'framer-motion'
import AvatarBot from '@/assets/avatarBot.png'
import AIIcon from '@/assets/ai-icon.png'
interface ChatWelcomeProps {
toolName?: string
}
export const ChatWelcome: React.FC<ChatWelcomeProps> = ({ toolName: _toolName }) => {
const viteOutputObj = import.meta.env.VITE_OUTPUT_OBJ || 'open'
// 根据不同的 toolName 显示不同的提示语
const getWelcomeText = () => {
const currentToolId = typeof window !== 'undefined' ? sessionStorage.getItem('currentToolId') : ''
if (currentToolId === '6712395743240') {
return 'HI~我是您的数据助手,可以帮你查询业务数据哦'
}
if (currentToolId === '6712395743241') {
return 'HI~我是您的提质增效助手,有什么可以帮您?'
}
return '您好,有什么我可以帮您的吗?'
}
return (
<div className="chatWelcomeContainer w-full">
<div className="h-[20px] sm:h-[32px] w-full"></div>
<div className="flex">
<Avatar className="mr-[12px] hidden sm:block flex-shrink-0" src={viteOutputObj === 'inner' ? AIIcon : AvatarBot} />
<motion.div
className="sm:ml-[20px] rounded-[20px] box-border px-[16px] py-[16px] sm:px-[24px] sm:py-[20px]"
style={{ background: '#F7FAFD' }}
>
<div className="content">
<p
className="font-medium text-[#333]"
style={{ fontSize: '16px' }}
>
{getWelcomeText()}
</p>
{/* <p className="text-[15px] mt-[4px] sm:mt-[8px] sm:text-13px text-[#27353C] font-300">作为您的智能保险伙伴,您有各类专业相关的问题都可以抛给我哟~让我们互相帮助共同成长吧~</p> */}
</div>
</motion.div>
</div>
<div className="h-[20px] sm:h-[32px] w-full"></div>
</div>
)
}
import type { ChatRecord, OriginalRecord } from '@/types/chat'
export function processApiResponse(data: OriginalRecord[]): ChatRecord[] {
const chatRecord: ChatRecord[] = []
if (data.length === 0)
return chatRecord
data.forEach((record) => {
chatRecord.push({
role: 'user',
...record,
})
if (record.answerList && record.answerList.length > 0) {
record.answerList.forEach((answer) => {
answer.isShow = true
})
chatRecord.push({
role: 'ai',
answerList: record.answerList,
question: record.question,
toolId: record.toolId,
})
}
})
return chatRecord
}
export { Chat } from './Chat'
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