Commit a3dec971 by Liu

feat:部分数据分析页面

parent 6bf7f2b8
import React, { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Button, Tooltip } from '@heroui/react'
import { useLocalStorageState, useToggle } from 'ahooks'
import { useLocation, useSearchParams } from 'react-router-dom'
import { LoginModal } from '../LoginModal'
import type { RootState } from '@/store'
import SendIcon from '@/assets/svg/send.svg?react'
import { type WithAuthProps, withAuth } from '@/auth/withAuth'
import { useAppDispatch, useAppSelector } from '@/store/hook'
import { clearCurrentToolId, createConversation, setCurrentToolId } from '@/store/conversationSlice'
import { fetchToolList } from '@/api/home'
import { getUserRolesForApi, safeSessionStorageGetItem, safeSessionStorageRemoveItem, safeSessionStorageSetItem } from '@/lib/utils'
interface ChatEditorProps {
onChange?: (value: string) => void
onFocus?: () => void
onSubmit?: (value: string, toolId?: string) => void
onToolClick?: (isToolBtn: boolean, toolId?: string, toolName?: string, shouldChangeStyle?: boolean) => void
placeholders: string[]
showContentTips?: boolean
initialValue?: string
}
// onToolClick
const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, onChange, onFocus, onSubmit, onToolClick, placeholders, showContentTips = false, initialValue = '' }) => {
// const dispatch = useAppDispatch()
const [content, setContent] = useState(initialValue)
const dispatch = useAppDispatch()
const editorRef = useRef<HTMLDivElement>(null)
const [currentPlaceholder, setCurrentPlaceholder] = useState(0)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const [isOpenLoginModal, isOpenLoginModalActions] = useToggle()
const isAsking = useAppSelector((state: RootState) => state.chat.isAsking)
const [toolList, setToolList] = useState<any[]>([])
const [selectedToolId, setSelectedToolId] = useState<string | null>(null)
const [isToolBtnActive, setIsToolBtnActive] = useState<boolean>(true)
const currentToolId = useAppSelector((state: RootState) => state.conversation.currentToolId)
const [showToolQuestion, setShowToolQuestion] = useState<boolean>(false)
const [sessionToolId, setSessionToolId] = useState<string | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const toolIdFromUrl = searchParams.get('toolId')
const fromCollect = location.state?.fromCollect
// 获取工具列表
const getToolList = async () => {
try {
// 从路由中获取 userRoles 参数
const userRoles = getUserRolesForApi()
// 调用真实 API 获取工具列表
const res = await fetchToolList({ userRoles })
if (res?.data && Array.isArray(res.data) && res.data.length > 0) {
setToolList(res.data)
}
}
catch (error) {
console.error('获取工具列表失败:', error)
}
}
// 根据 currentToolId 以及 sessionStorage 中的记录决定高亮逻辑
useEffect(() => {
if (currentToolId && !sessionToolId && !fromCollect) {
// 清除过期的 Redux 值
dispatch(clearCurrentToolId())
// 如果 URL 中还有 toolId,也清除它
if (toolIdFromUrl) {
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete('toolId')
setSearchParams(newSearchParams, { replace: true })
}
setSelectedToolId(null)
setIsToolBtnActive(true)
return
}
if (!currentToolId && !sessionToolId && toolIdFromUrl) {
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete('toolId')
setSearchParams(newSearchParams, { replace: true })
setSelectedToolId(null)
setIsToolBtnActive(true)
return
}
// 只有当 sessionStorage 或 URL 中还能找到 toolId 时,才认为 Redux 中的 currentToolId 仍然有效
const hasPersistentToolSource = Boolean(sessionToolId || toolIdFromUrl)
if (currentToolId && hasPersistentToolSource) {
setSelectedToolId(currentToolId)
setIsToolBtnActive(false)
return
}
if (sessionToolId) {
setSelectedToolId(sessionToolId)
setIsToolBtnActive(false)
return
}
// 如果 currentToolId 和 sessionToolId 都没有值,根据路由中的 toolId 来决定
if (toolIdFromUrl) {
setSelectedToolId(toolIdFromUrl)
setIsToolBtnActive(false)
}
else {
setSelectedToolId(null)
setIsToolBtnActive(true)
}
}, [currentToolId, sessionToolId, toolIdFromUrl, searchParams, setSearchParams, fromCollect])
// 监听 sessionStorage 中的 currentToolId(历史点击时写入)来辅助高亮逻辑
useEffect(() => {
const syncSessionToolId = () => {
const storedToolId = safeSessionStorageGetItem('currentToolId')
// 如果 currentToolId 是空字符串,视为 null,确保通用模式能正确高亮
setSessionToolId(storedToolId && storedToolId.trim() ? storedToolId : null)
}
syncSessionToolId()
window.addEventListener('storage', syncSessionToolId)
// 监听强制重置为通用模式的事件(登录成功后触发)
const handleForceReset = () => {
// 强制同步 sessionStorage(此时应该已经被清除了)
syncSessionToolId()
// 确保 Redux 也被清除(如果还没有)
if (currentToolId) {
dispatch(clearCurrentToolId())
}
// 强制设置为通用模式
setSelectedToolId(null)
setIsToolBtnActive(true)
}
window.addEventListener('forceResetToGeneralMode', handleForceReset)
return () => {
window.removeEventListener('storage', syncSessionToolId)
window.removeEventListener('forceResetToGeneralMode', handleForceReset)
}
}, [currentToolId, dispatch])
// 当路由变化时,同步更新 sessionToolId(因为 storage 事件不会在同标签页触发)
useEffect(() => {
const storedToolId = safeSessionStorageGetItem('currentToolId')
// 如果 currentToolId 是空字符串,视为 null,确保通用模式能正确高亮
setSessionToolId(storedToolId && storedToolId.trim() ? storedToolId : null)
}, [toolIdFromUrl])
const startAnimation = () => {
intervalRef.current = setInterval(() => {
setCurrentPlaceholder(prev => (prev + 1) % placeholders.length)
}, 3000)
}
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible' && intervalRef.current) {
clearInterval(intervalRef.current) // Clear the interval when the tab is not visible
intervalRef.current = null
}
else if (document.visibilityState === 'visible') {
startAnimation() // Restart the interval when the tab becomes visible
}
}
const handleInput = () => {
if (editorRef.current) {
const newContent = editorRef.current.textContent || ''
setContent(newContent)
onChange?.(newContent)
}
}
const handleSubmit = () => {
if (isAsking)
return
if (checkAuth()) {
if (content.trim()) {
// 只在提质增效模式下传递 toolId,通用模式不传
let toolId: string | undefined
if (selectedToolId) {
// 提质增效模式:使用选中的 toolId
toolId = selectedToolId
}
else {
// 通用模式:不传递 toolId
toolId = undefined
}
onSubmit?.(content.trim(), toolId)
setContent('')
if (editorRef.current) {
editorRef.current.textContent = ''
}
}
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
else if (e.key === 'Backspace' && !content) {
e.preventDefault() // 防止删除最后一个字符后继续删除
}
}
const handleCloseLoginModal = () => {
isOpenLoginModalActions.setLeft()
}
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
event.preventDefault()
const text = event.clipboardData.getData('text/plain')
document.execCommand('insertText', false, text)
}
// 处理通用模式按钮点击:先创建新会话
const handleGeneralClick = async () => {
if (!checkAuth())
return
// 先更新 Redux,确保状态同步
dispatch(clearCurrentToolId())
// 立即更新本地状态,让 UI 立即响应
setIsToolBtnActive(true)
setSelectedToolId(null)
safeSessionStorageRemoveItem('showToolQuestion')
safeSessionStorageRemoveItem('currentToolId')
setSessionToolId(null)
setShowToolQuestion(false)
// 先通知上层更新欢迎语(即便后续接口异常也能生效)
onToolClick?.(true, undefined, '通用模式', false)
// 清空路由中的 toolId 参数
if (toolIdFromUrl) {
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete('toolId')
setSearchParams(newSearchParams, { replace: true })
}
try {
await dispatch(createConversation({
conversationData: {},
shouldNavigate: true,
shouldSendQuestion: '',
})).unwrap()
}
catch (error) {
console.error('创建会话失败:', error)
}
}
// 处理工具按钮点击:先创建新会话,再切换工具
const handleToolClick = async (tool: any) => {
if (tool.toolName === '数据助手') {
safeSessionStorageSetItem('showToolQuestion', 'true')
setShowToolQuestion(true)
}
else {
safeSessionStorageRemoveItem('showToolQuestion')
setShowToolQuestion(false)
}
dispatch(setCurrentToolId(tool.toolId))
setSelectedToolId(tool.toolId)
setIsToolBtnActive(false)
safeSessionStorageSetItem('currentToolId', tool.toolId)
setSessionToolId(tool.toolId)
// 先通知上层更新欢迎语(即便后续接口异常也能生效)
onToolClick?.(false, tool.toolId, tool.toolName, true)
try {
await dispatch(createConversation({
conversationData: { toolId: tool.toolId },
shouldNavigate: true,
shouldSendQuestion: '',
})).unwrap()
}
catch (error) {
console.error('创建会话失败:', error)
}
}
useEffect(() => {
startAnimation()
document.addEventListener('visibilitychange', handleVisibilityChange)
if (editorRef.current) {
editorRef.current.style.height = 'auto'
editorRef.current.style.height = `${editorRef.current.scrollHeight}px`
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [content])
// 组件加载时和路由参数变化时获取工具列表
useEffect(() => {
getToolList()
}, [location.pathname, location.search])
// 监听 sessionStorage 中的 showToolQuestion
useEffect(() => {
const checkShowToolQuestion = () => {
const value = safeSessionStorageGetItem('showToolQuestion')
setShowToolQuestion(value === 'true')
}
checkShowToolQuestion()
// 监听 storage 事件以响应其他标签页的变化
window.addEventListener('storage', checkShowToolQuestion)
return () => {
window.removeEventListener('storage', checkShowToolQuestion)
}
}, [])
// 处理initialValue的变化
useEffect(() => {
if (initialValue && editorRef.current) {
setContent(initialValue)
editorRef.current.textContent = initialValue
// 触发高度调整
editorRef.current.style.height = 'auto'
editorRef.current.style.height = `${editorRef.current.scrollHeight}px`
}
}, [initialValue])
const [token] = useLocalStorageState<string | undefined>(
'__TOKEN__',
{
defaultValue: '',
listenStorageChange: true,
},
)
return (
<div
className="flex items-end w-full h-auto relative mx-auto rounded-[24px] overflow-hidden transition duration-200 py-[8px] pl-[12px] pr-[12px] sm:py-[12px] sm:pl-[32px] sm:pr-[20px]"
style={{
background: '#FFFFFF',
border: '1px solid #0a0a0a78',
boxShadow: '0 8px 12px 0 rgba(235,235,235,0.30)',
...(toolList && toolList.length > 0 ? { height: '102px' } : {}),
}}
>
<div
ref={editorRef}
contentEditable
enterKeyHint="send"
role="textbox"
translate="no"
className="w-full min-h-[40px] word-break-break-all max-h-[48px] p-2 rounded overflow-y-auto outline-none"
onInput={handleInput}
onFocus={onFocus}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
suppressContentEditableWarning
data-placeholder={showToolQuestion ? '输入您想问的问题吧~' : '输入您想问的问题吧~'}
style={{
resize: 'none',
maxHeight: '48px',
wordBreak: 'break-all',
...(toolList && toolList.length > 0 ? { marginBottom: '40px' } : {}),
}}
>
</div>
<Tooltip isOpen={Boolean(token) && showContentTips && !isAsking && !content} color="foreground" content="请输入您的问题📖" placement="top-end">
<Button className="ask-send" onPress={handleSubmit} radius="full" isDisabled={!content || isAsking} isIconOnly color="primary">
<SendIcon />
</Button>
</Tooltip>
<div className="absolute inset-0 flex items-center rounded-full pointer-events-none">
<AnimatePresence mode="wait">
{!content && (
<motion.p
initial={{
y: 5,
opacity: 0,
}}
key={`current-placeholder-${currentPlaceholder}`}
animate={{
y: 0,
opacity: 1,
}}
exit={{
y: -15,
opacity: 0,
}}
transition={{
duration: 0.3,
ease: 'linear',
}}
className="dark:text-zinc-500 text-[14px] sm:text-[14px] font-normal text-[#3333334d] pl-4 sm:pl-12 text-left w-[calc(100%-2rem)] truncate"
>
</motion.p>
)}
</AnimatePresence>
</div>
{toolList && toolList.length > 0 && (
<div className="absolute left-4 bottom-2 flex items-center gap-3 pointer-events-auto pl-[16px]">
{toolList.map((tool: any, index: number) => {
// 根据 selectedToolId 或路由中的 toolId 进行匹配,注意数据类型统一转换为字符串比较
// 优先使用 selectedToolId(用户点击后的状态),其次使用 sessionStorage 中的 currentToolId(刷新时使用),再次使用路由中的 toolId(初始化时使用)
const toolIdStr = String(tool.toolId)
const isSelectedByState = selectedToolId && toolIdStr === String(selectedToolId)
const isSelectedBySession = !selectedToolId && sessionToolId && toolIdStr === String(sessionToolId)
const isSelectedByUrl = !selectedToolId && !sessionToolId && toolIdFromUrl && toolIdStr === String(toolIdFromUrl)
// 通用模式高亮:路由内没有 toolId 或 toolId 为空时默认高亮,点击后也要高亮
const isGeneralMode = tool.toolName === '通用模式' && isToolBtnActive && !selectedToolId && !sessionToolId && !toolIdFromUrl
const isSelected = isSelectedByState || isSelectedBySession || isSelectedByUrl || isGeneralMode
const baseBtnClass
= 'w-auto h-[32px] px-3 rounded-full shadow-none text-[12px] flex items-center gap-2 transition-all duration-200 border'
const selectedClass = isSelected
? ' bg-[#F3F7FF] border-[#AECBFF] text-[#165DFF]'
: ' bg-[#FFFFFF] border-[#E6E8EB] text-[#111827]'
const selectedVariant = isSelected ? 'solid' : 'bordered'
const selectedColor = isSelected ? 'primary' : 'default'
const handleButtonPress = async () => {
// 高亮状态直接返回,避免重复触发
if (isSelected)
return
if (tool.toolName === '通用模式')
await handleGeneralClick()
else
await handleToolClick(tool)
}
// 处理后端返回的 base64 图标
const getToolIconSrc = () => {
const icon = tool.toolIcon
if (!icon)
return ''
// 已经是完整的 data URL 或 http 链接时直接返回
if (icon.startsWith('data:') || icon.startsWith('http'))
return icon
// 否则拼接为 base64 图片格式
return `data:image/png;base64,${icon}`
}
return (
<Button
key={tool.toolId || `tool-${index}`}
className={`${baseBtnClass}${selectedClass}`}
radius="full"
variant={selectedVariant}
color={selectedColor}
onPress={handleButtonPress}
>
{tool.toolIcon && (
<img
src={getToolIconSrc()}
className="w-4 h-4 flex-shrink-0"
/>
)}
{tool.toolName}
</Button>
)
})}
</div>
)}
<LoginModal onClose={handleCloseLoginModal} isOpen={isOpenLoginModal} />
</div>
)
}
export const ChatEditor = withAuth(ChatEditorBase)
.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
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { Button } from '@heroui/react'
import { motion } from 'framer-motion'
import { useScroll } from 'ahooks'
import styles from './Chat.module.less'
import { processApiResponse } from './helper'
import { ChatWelcome } from './components/ChatWelcome'
import { ChatItemUser } from './components/ChatItem/ChatItemUser'
import { ChatAnswerBox } from './components/ChatItem/ChatAnswerBox'
import { ChatEditor } from '@/components/ChatEditorTactics'
import type { ChatRecord } from '@/types/chat'
import { fetchUserQaRecordPage } from '@/api/conversation'
import { fetchCheckTokenApi, fetchStreamResponse } from '@/api/chat'
import { fetchEfficiencyQuestionList, fetchToolList } from '@/api/home'
// import { mockFetchToolList } from '@/api/mock/home'
import { clearCurrentToolId, clearShouldSendQuestion, fetchConversations, setCurrentToolId } from '@/store/conversationSlice'
import { getUserRolesForApi } from '@/lib/utils'
import type { RootState } from '@/store'
import { useAppDispatch, useAppSelector } from '@/store/hook'
import ScrollBtoIcon from '@/assets/svg/scrollBto.svg?react'
import { setIsAsking } from '@/store/chatSlice'
import SdreamLoading from '@/components/SdreamLoading'
export const Chat: React.FC = () => {
const { id } = useParams<{ id: string }>()
const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
// 优先从 URL 查询参数读取 toolId(刷新后仍能保留),其次从 location.state 读取
const rawToolIdFromUrl = searchParams.get('toolId')
// 只有在非 SPA 导航(location.state 不存在)且链接携带 toolId 时才清空,避免影响站内点击历史记录
const shouldForceClearUrlToolId = !location.state && Boolean(rawToolIdFromUrl)
const toolIdFromUrl = shouldForceClearUrlToolId ? null : rawToolIdFromUrl
// 添加调试日志,查看 location.state 的实际值
// eslint-disable-next-line no-console
console.log('[Chat] location.state:', location.state)
const toolIdFromState = (location.state as { toolId?: string | null } | null)?.toolId
// 优先使用 URL 中的 toolId,其次使用 state 中的 toolId
const initialToolId = toolIdFromUrl !== null ? toolIdFromUrl : toolIdFromState
// eslint-disable-next-line no-console
console.log('[Chat] initialToolId:', {
fromUrl: toolIdFromUrl,
fromState: toolIdFromState,
final: initialToolId,
})
const [isLoading, setIsLoading] = useState(false)
const [allItems, setAllItems] = useState<ChatRecord[]>([])
const dispatch = useAppDispatch()
const { shouldSendQuestion, currentToolId, conversations } = useAppSelector((state: RootState) => state.conversation)
const scrollableRef = useRef<HTMLDivElement | any>(null)
const position = useScroll(scrollableRef)
const currentIdRef = useRef<string | undefined>(id)
const lastSentQuestionRef = useRef<string>('')
const abortControllerRef = useRef<AbortController | null>(null)
const [currentToolName, setCurrentToolName] = useState<string | undefined>(undefined)
// 使用 ref 保存从 location.state 传递的 toolId,避免被异步操作覆盖
const toolIdFromStateRef = useRef<string | null | undefined>(undefined)
// 当外部系统直接以 /chat/:id 链接进入(没有 location.state,且 URL 中也没有 toolId)时,
// 视为一次新的会话入口:重置为通用模式,清除历史遗留的工具模式状态
useEffect(() => {
if (!location.state && !rawToolIdFromUrl) {
dispatch(clearCurrentToolId())
// sessionStorage.removeItem('currentToolId')
}
}, [location.state, rawToolIdFromUrl, dispatch])
// 若链接中带有 toolId,进入页面后强制清空(避免沿用历史链接参数)
useEffect(() => {
if (!shouldForceClearUrlToolId)
return
// 1. 清除 Redux 中的 currentToolId
dispatch(clearCurrentToolId())
// 2. 清除 sessionStorage 中的 currentToolId
// sessionStorage.removeItem('currentToolId')
// 3. 清除 URL 中的 toolId 参数(如果存在)
const currentUrl = new URL(window.location.href)
if (currentUrl.searchParams.has('toolId')) {
currentUrl.searchParams.delete('toolId')
window.history.replaceState({}, '', currentUrl.toString())
}
// 4. 触发自定义事件,通知 ChatEditor 强制重置为通用模式
window.dispatchEvent(new CustomEvent('forceResetToGeneralMode'))
// 同步 react-router 的 searchParams 状态
const newParams = new URLSearchParams(searchParams)
newParams.delete('toolId')
setSearchParams(newParams, { replace: true })
}, [searchParams, setSearchParams, shouldForceClearUrlToolId, dispatch])
// 调试用:展示当前页面链接与缓存中的 toolId
const [debugInfo, setDebugInfo] = useState<{ href: string, sessionToolId: string | null }>({
href: '',
sessionToolId: null,
})
// 首次进入 /chat/:id 时,拉取一次通用模式下的常见问题(toolId: ''),后续仍按现有逻辑走
useEffect(() => {
;(async () => {
try {
// 仅在本标签页首次进入时调用一次
if (sessionStorage.getItem('__INITIAL_FAQ_LOADED__'))
return
sessionStorage.setItem('__INITIAL_FAQ_LOADED__', 'true')
const sessionToolId = sessionStorage.getItem('currentToolId') || ''
await fetchEfficiencyQuestionList({ toolId: sessionToolId })
}
catch (error) {
console.error('初始化通用模式常见问题失败:', error)
}
})()
}, [])
// 进入聊天页时,同步当前链接和缓存中的 toolId 到页面上展示
useEffect(() => {
try {
const href = window.location.href
const sessionToolId = sessionStorage.getItem('currentToolId')
setDebugInfo({
href,
sessionToolId,
})
}
catch (error) {
console.error('同步页面链接与缓存信息失败:', error)
}
}, [])
useEffect(() => {
if (!debugInfo.href && !debugInfo.sessionToolId)
return
// eslint-disable-next-line no-console
console.debug('[Chat] 当前链接 / 缓存 toolId:', debugInfo)
}, [debugInfo])
// 历史记录点击时将 toolId 通过路由 state 传入,优先使用该值快速同步高亮
useEffect(() => {
// 保存从 location.state 传递的 toolId 到 ref
toolIdFromStateRef.current = initialToolId
if (typeof initialToolId === 'undefined')
return
if (initialToolId) {
// 统一转换为字符串,确保类型一致(真实API可能返回数字,需要转换为字符串)
const normalizedToolId = String(initialToolId)
// eslint-disable-next-line no-console
console.log('[Chat] 从路由state设置toolId:', {
originalToolId: initialToolId,
originalType: typeof initialToolId,
normalizedToolId,
normalizedType: typeof normalizedToolId,
})
dispatch(setCurrentToolId(normalizedToolId))
}
else {
dispatch(clearCurrentToolId())
}
}, [dispatch, initialToolId])
/** 处理正常stream的数据 */
const handleStreamMesageData = (msg: any, question: string) => {
setAllItems((prevItems) => {
const newItems = [...prevItems] // 创建数组的浅拷贝
const lastIndex = newItems.length - 1
if (lastIndex >= 0) {
// 创建最后一项的新对象,合并现有数据和新的 answer
const originalAnswer = (newItems[lastIndex].answerList?.[0]?.answer || '') + msg.content.data.answer
// 移除所有括号及其内容
let filteredAnswer = originalAnswer.replace(/\([^)]*\)/g, '').trim()
// 去除 [参考文档《任意内容》 《任意内容》...] 格式的内容
filteredAnswer = filteredAnswer.replace(/\[参考文档(?:[^]*》\s*)+\]/g, '').trim()
newItems[lastIndex] = {
...newItems[lastIndex],
question,
answerList: [
{
...msg.content.data,
isShow: false,
answer: filteredAnswer,
},
],
}
}
return newItems
})
}
/** 处理超过最大条数的数据 */
const handleChatMaxCount = (msg: any, question: string) => {
// toast(t => (
// <div className="flex items-center">
// <p className="text-[14px]">⚠️ 超过最大轮数上限!请新建对话 👉🏻</p>
// <Button
// color="primary"
// size="sm"
// variant="light"
// isIconOnly
// onClick={() => {
// dispatch(createConversation({
// conversationData: {},
// shouldNavigate: true,
// shouldSendQuestion: '',
// }))
// toast.dismiss(t.id)
// }}
// >
// <AddNewChat />
// </Button>
// </div>
// ), {
// position: 'bottom-center',
// duration: 0,
// style: {
// marginBottom: '120px',
// },
// })
setAllItems((prevItems) => {
const newItems = [...prevItems] // 创建数组的浅拷贝
const lastIndex = newItems.length - 1
if (lastIndex >= 0) {
// 创建最后一项的新对象,合并现有数据和新的 answer
newItems[lastIndex] = {
...newItems[lastIndex],
question,
answerList: [
{
...msg.content.data,
isShow: false,
isChatMaxCount: true,
endAnswerFlag: true,
answer: '已达上限',
},
],
}
}
return newItems
})
}
/** 提交问题 */
const handleSubmitQuestion = async (question: string, productCode?: string, toolId?: string) => {
const resolvedToolId = toolId ?? currentToolId ?? undefined
// 停止之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const isNew = allItems.length <= 1
dispatch(setIsAsking(true))
// 检查token
await fetchCheckTokenApi()
// 一次性添加用户问题和空的AI回答
setAllItems(prevItems => [
...prevItems,
{
role: 'user',
question,
} as ChatRecord,
{
role: 'ai',
answerList: [{ answer: '' }],
} as ChatRecord,
])
// 创建新的 AbortController
abortControllerRef.current = new AbortController()
let fetchUrl = `/conversation/api/conversation/mobile/v1/submit_question_stream`
const viteOutputObj = import.meta.env.VITE_OUTPUT_OBJ || 'open'
let proxy = ''
if (viteOutputObj === 'open') {
proxy = import.meta.env.MODE !== 'prod' ? '/api' : '/dev-sdream-api'
}
else {
proxy = import.meta.env.MODE === 'dev' ? '/api' : '/dev-sdream-api'
}
fetchUrl = proxy + fetchUrl
fetchStreamResponse(
fetchUrl,
{
question,
conversationId: currentIdRef.current,
stream: true,
productCode,
toolId: resolvedToolId,
},
(msg) => {
// 检查是否已被取消
if (abortControllerRef.current?.signal.aborted) {
return
}
// 处理错误
if (msg?.type === 'ERROR') {
// 如果是 AbortError,不显示错误
if (msg.content?.name === 'AbortError') {
return
}
return
}
// 正常的stream数据
if (msg?.type === 'DATA' && msg?.content?.code === '00000000') {
handleStreamMesageData(msg, question)
}
if (msg?.type === 'DATA' && msg?.content?.code === '01010005') {
handleChatMaxCount(msg, question)
return
}
if (msg.type === 'END') {
if (isNew) {
setTimeout(() => {
dispatch(fetchConversations())
}, 2000)
}
}
},
abortControllerRef.current.signal,
)
}
/** 获取qa记录 */
const getUserQaRecordPage = useCallback(async (conversationId: string) => {
setIsLoading(true)
try {
// 检测是否从收藏页返回
const fromCollect = location.state?.fromCollect
// eslint-disable-next-line no-console
console.log('[Chat] 开始获取历史记录:', conversationId)
const res = await fetchUserQaRecordPage(conversationId)
const qaRecords = res.data || []
const messages = [{ role: 'system' } as ChatRecord, ...processApiResponse(qaRecords)]
// 处理历史记录中的参考文档标记
const processedMessages = messages.map((item) => {
if (item.role === 'ai' && item.answerList?.[0]?.answer) {
return {
...item,
answerList: item.answerList.map(answerItem => ({
...answerItem,
answer: answerItem.answer
?.replace(/\([^)]*\)/g, '')
.replace(/\[参考文档(?:[^]*》\s*)+\]/g, '')
.trim(),
})),
}
}
return item
})
setAllItems(processedMessages)
// 优先从 qaRecords 中查找 toolId(这是实际使用的)
const latestToolId = [...qaRecords].reverse().find(item => Boolean(item.toolId))?.toolId?.trim?.()
const hasQaRecords = qaRecords.length > 0
// 如果 qaRecords 中没有 toolId,尝试从 conversations 中查找当前会话的 toolId(会话级别)
const conversationToolId = latestToolId || (conversations.find(conv => conv.conversationId === conversationId)?.toolId?.trim?.())
// 优先使用从 location.state 传递的 toolId(如果存在),这是从历史记录点击传递过来的
const toolIdFromState = toolIdFromStateRef.current !== undefined
? (toolIdFromStateRef.current ? String(toolIdFromStateRef.current) : null)
: undefined
// eslint-disable-next-line no-console
console.log('[Chat] 从历史记录获取 toolId:', {
conversationId,
toolIdFromState,
latestToolIdFromQaRecords: latestToolId,
conversationToolId,
hasQaRecords,
currentToolIdInRedux: currentToolId,
})
// 确定最终使用的 toolId:优先使用从 location.state 传递的,其次使用 qaRecords 中的,最后使用 conversation 中的
// 如果从 location.state 传递了 toolId,直接使用它(最高优先级)
if (toolIdFromState !== undefined) {
if (toolIdFromState) {
// 只有当 Redux 中的 toolId 与最终确定的 toolId 不一致时,才更新
if (currentToolId !== toolIdFromState) {
// eslint-disable-next-line no-console
console.log('[Chat] 使用从路由state传递的 toolId:', {
from: currentToolId,
to: toolIdFromState,
source: 'location.state',
})
dispatch(setCurrentToolId(toolIdFromState))
// 从收藏返回时,同步到 sessionStorage,避免 ChatEditor 清除 toolId
if (fromCollect) {
sessionStorage.setItem('currentToolId', toolIdFromState)
}
}
else {
// eslint-disable-next-line no-console
console.log('[Chat] toolId 已一致,无需更新:', toolIdFromState)
// 从收藏返回时,确保 sessionStorage 中有值
if (fromCollect && !sessionStorage.getItem('currentToolId')) {
sessionStorage.setItem('currentToolId', toolIdFromState)
}
}
}
else {
// 如果从 location.state 传递的是 null,清除 toolId
if (currentToolId) {
// eslint-disable-next-line no-console
console.log('[Chat] 清除 toolId (从路由state传递null)')
dispatch(clearCurrentToolId())
}
}
// 清除 ref,避免下次路由变化时误用
toolIdFromStateRef.current = undefined
}
else {
// 如果没有从 location.state 传递 toolId(可能是嵌套路由传递失败),使用原来的逻辑
// 优先使用 qaRecords 中的 toolId,其次使用 conversation 中的 toolId
const finalToolId = latestToolId || conversationToolId || undefined
if (finalToolId) {
// 只有当 Redux 中的 toolId 与最终确定的 toolId 不一致时,才更新
if (currentToolId !== finalToolId) {
// eslint-disable-next-line no-console
console.log('[Chat] 更新 toolId (不一致):', {
from: currentToolId,
to: finalToolId,
source: latestToolId ? 'qaRecords' : 'conversation',
})
dispatch(setCurrentToolId(finalToolId))
// 从收藏返回时,同步到 sessionStorage,避免 ChatEditor 清除 toolId
if (fromCollect) {
sessionStorage.setItem('currentToolId', finalToolId)
}
}
else {
// eslint-disable-next-line no-console
console.log('[Chat] toolId 已一致,无需更新:', finalToolId)
// 从收藏返回时,确保 sessionStorage 中有值
if (fromCollect && !sessionStorage.getItem('currentToolId')) {
sessionStorage.setItem('currentToolId', finalToolId)
}
}
}
else {
// 如果 qaRecords 和 conversation 中都没有 toolId
// 如果有历史记录但没有 toolId,说明是通用模式,应该清除
if (hasQaRecords && currentToolId) {
// eslint-disable-next-line no-console
console.log('[Chat] 清除 toolId (qaRecords 中有记录但没有 toolId,通用模式)')
dispatch(clearCurrentToolId())
}
// 如果没有历史记录,可能是新会话,但如果 Redux 中已经有 toolId(从 HistoryBarList 设置的),暂时保留
// 因为可能是刚点击历史记录但 API 还没返回,或者 location.state 传递失败但 Redux 中已有正确的值
else if (!hasQaRecords && currentToolId) {
// eslint-disable-next-line no-console
console.log('[Chat] 没有历史记录,保留 Redux 中的 toolId (可能是 location.state 传递失败):', currentToolId)
// 从收藏返回时,确保 sessionStorage 中有值
if (fromCollect && !sessionStorage.getItem('currentToolId')) {
sessionStorage.setItem('currentToolId', currentToolId)
}
}
}
}
}
catch {
// 错误处理
}
finally {
setIsLoading(false)
}
}, [dispatch, currentToolId, conversations, location.state])
/** 点击滚动到底部 */
const scrollToBottom = () => {
scrollableRef.current.scrollTo(scrollableRef.current.scrollHeight, { behavior: 'smooth' })
}
useEffect(() => {
if (id) {
// 停止之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort()
dispatch(setIsAsking(false))
}
currentIdRef.current = id
lastSentQuestionRef.current = '' // 重置标记
getUserQaRecordPage(id)
}
}, [id])
// 处理shouldSendQuestion的变化 - 自动发送问题
useEffect(() => {
if (
shouldSendQuestion
&& currentIdRef.current
&& !isLoading
&& shouldSendQuestion !== lastSentQuestionRef.current
) {
lastSentQuestionRef.current = shouldSendQuestion
// 立即清除shouldSendQuestion,防止重复发送
dispatch(clearShouldSendQuestion())
// 确保历史记录加载完成后再发送问题
setTimeout(() => {
handleSubmitQuestion(shouldSendQuestion, undefined, currentToolId)
}, 100)
}
}, [shouldSendQuestion, isLoading, currentToolId])
// 根据 currentToolId 获取对应的 toolName
useEffect(() => {
const getToolNameFromToolId = async () => {
if (currentToolId) {
try {
// 使用mock数据(已注释)
// const res = await mockFetchToolList()
// 真实API调用
const userRoles = getUserRolesForApi()
const res = await fetchToolList({ userRoles })
if (res?.data) {
const tool = res.data.find((t: any) => t.toolId === currentToolId)
if (tool?.toolName) {
setCurrentToolName(tool.toolName)
}
}
}
catch (error) {
console.error('获取工具列表失败:', error)
}
}
else {
// 通用模式
setCurrentToolName('通用模式')
}
}
getToolNameFromToolId()
}, [currentToolId])
// 监听工具按钮点击事件,更新 ChatWelcome 提示语和 toolId
useEffect(() => {
const handleToolClickEvent = (event: CustomEvent) => {
const { isToolBtn, toolId, toolName } = event.detail
// 保存当前选择的 toolName
setCurrentToolName(toolName)
// 保存当前选择的 toolId 到 Redux
if (!isToolBtn && toolId) {
// 提质增效模式,保存 toolId
dispatch(setCurrentToolId(toolId))
}
else {
// 通用模式,清除 toolId
dispatch(clearCurrentToolId())
}
}
window.addEventListener('toolButtonClick', handleToolClickEvent as EventListener)
return () => {
window.removeEventListener('toolButtonClick', handleToolClickEvent as EventListener)
}
}, [dispatch])
return (
<div className={styles.scrollView}>
<div className={`${styles.chatPage} relative`}>
{/* <ChatSlogan />
<ChatMaskBar /> */}
<div className={`${styles.content}`}>
{isLoading && (
<div className="w-full h-full flex justify-center items-center">
<SdreamLoading />
</div>
)}
{!isLoading && (
<motion.div
ref={scrollableRef}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
opacity: { duration: 0.1 },
}}
className={`${styles.scrollable} scrollbar-hide scroll-smooth`}
>
<div className={styles.inter}>
{allItems.map((record, index) => {
const recordId = record.answerList?.[0]?.recordId || record.groupId
const uniqueKey = recordId
? `${record.role}-${recordId}`
: `${record.role}-${record.question || record.answerList?.[0]?.answer || ''}-${index}`
return (
<div
className="w-full chatItem mx-auto"
key={uniqueKey}
>
{record.role === 'system' && <ChatWelcome toolName={currentToolName} />}
{record.role === 'user' && <ChatItemUser record={record} />}
{record.role === 'ai' && (
<ChatAnswerBox
onSubmitQuestion={handleSubmitQuestion}
isLastAnswer={index === allItems.length - 1}
showIndex={0}
record={record}
index={index}
/>
)}
</div>
)
})}
</div>
</motion.div>
)}
</div>
<div className="relative box-border px-[0] mx-auto iptContainer w-full flex-shrink-0 sm:px-0 pb-[18px]">
<div className="absolute left-1/2 ml-[-20px] top-[-45px] sm:top-[-65px]">
<motion.div
initial="hidden"
animate={(position?.top as number) < -20 ? 'visible' : 'hidden'}
variants={{
hidden: { opacity: 0, y: 20, pointerEvents: 'none' as const },
visible: { opacity: 1, y: 0, pointerEvents: 'auto' as const },
}}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<Button onPress={scrollToBottom} radius="full" isIconOnly color="primary">
<ScrollBtoIcon />
</Button>
</motion.div>
</div>
<ChatEditor
onSubmit={(question, toolId) => handleSubmitQuestion(question, undefined, toolId)}
onToolClick={(isToolBtn, toolId, toolName, shouldChangeStyle) => {
// 发送自定义事件到父组件
window.dispatchEvent(new CustomEvent('toolButtonClick', {
detail: { isToolBtn, toolId, toolName, shouldChangeStyle },
}))
}}
placeholders={[]}
/>
<div className="hidden sm:block w-full text-center mt-[12px] text-[#3333334d] text-[12px]">
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
</div>
</div>
</div>
</div>
)
}
// 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