Commit e3e83b1c by Liu

feat: add chat tactics flow

parent 0b968cbd
// 问答功能独立的 API 接口
import http from '@/utils/request'
/**
* 查询问答功能会话列表
*/
export function fetchTacticsConversationPage<T>(data: T) {
return http.post('/conversation/api/conversation/mobile/v1/query_user_conversation_page', data)
}
/**
* 创建问答功能会话
*/
export function fetchCreateTacticsConversation<T>(data: T) {
return http.post('/conversation/api/conversation/mobile/v1/create_conversation', data)
}
/**
* 查询问答功能历史记录
*/
export function fetchTacticsQaRecordPage(conversationId: string) {
return http.post('/conversation/api/conversation/mobile/v1/query_user_qa_record_list', { conversationId })
}
/**
* 删除问答功能会话
*/
export function fetchDeleteTacticsConversation(conversationIdList: string[]) {
return http.post('/conversation/api/conversation/mobile/v1/delete_user_conversation', {
conversationIdList,
})
}
...@@ -20,9 +20,10 @@ interface ChatEditorProps { ...@@ -20,9 +20,10 @@ interface ChatEditorProps {
placeholders: string[] placeholders: string[]
showContentTips?: boolean showContentTips?: boolean
initialValue?: string initialValue?: string
hideTools?: boolean // 隐藏工具选择部分
} }
// onToolClick // onToolClick
const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, onChange, onFocus, onSubmit, onToolClick, placeholders, showContentTips = false, initialValue = '' }) => { const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, onChange, onFocus, onSubmit, onToolClick, placeholders, showContentTips = false, initialValue = '', hideTools = false }) => {
// const dispatch = useAppDispatch() // const dispatch = useAppDispatch()
const [content, setContent] = useState(initialValue) const [content, setContent] = useState(initialValue)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
...@@ -292,8 +293,10 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -292,8 +293,10 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
// 组件加载时和路由参数变化时获取工具列表 // 组件加载时和路由参数变化时获取工具列表
useEffect(() => { useEffect(() => {
if (!hideTools) {
getToolList() getToolList()
}, [location.pathname, location.search]) }
}, [location.pathname, location.search, hideTools])
// 监听 sessionStorage 中的 showToolQuestion // 监听 sessionStorage 中的 showToolQuestion
useEffect(() => { useEffect(() => {
...@@ -335,7 +338,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -335,7 +338,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
background: '#FFFFFF', background: '#FFFFFF',
border: '1px solid #0a0a0a78', border: '1px solid #0a0a0a78',
boxShadow: '0 8px 12px 0 rgba(235,235,235,0.30)', boxShadow: '0 8px 12px 0 rgba(235,235,235,0.30)',
...(toolList && toolList.length > 0 ? { height: '102px' } : {}), ...(!hideTools && toolList && toolList.length > 0 ? { height: '102px' } : {}),
}} }}
> >
<div <div
...@@ -355,7 +358,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -355,7 +358,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
resize: 'none', resize: 'none',
maxHeight: '48px', maxHeight: '48px',
wordBreak: 'break-all', wordBreak: 'break-all',
...(toolList && toolList.length > 0 ? { marginBottom: '40px' } : {}), ...(!hideTools && toolList && toolList.length > 0 ? { marginBottom: '40px' } : {}),
}} }}
> >
</div> </div>
...@@ -391,7 +394,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -391,7 +394,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
{toolList && toolList.length > 0 && ( {!hideTools && toolList && toolList.length > 0 && (
<div className="absolute left-4 bottom-2 flex items-center gap-3 pointer-events-auto pl-[16px]"> <div className="absolute left-4 bottom-2 flex items-center gap-3 pointer-events-auto pl-[16px]">
{toolList.map((tool: any, index: number) => { {toolList.map((tool: any, index: number) => {
// 根据 selectedToolId 或路由中的 toolId 进行匹配,注意数据类型统一转换为字符串比较 // 根据 selectedToolId 或路由中的 toolId 进行匹配,注意数据类型统一转换为字符串比较
......
...@@ -61,15 +61,18 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { ...@@ -61,15 +61,18 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
} }
}, [location.pathname]) }, [location.pathname])
// 判断是否为问答功能页面
const isTacticsPage = location.pathname.startsWith('/tactics')
return ( return (
<motion.main className={styles.layoutMain}> <motion.main className={styles.layoutMain}>
{/* hidden */} {/* 问答功能页面不显示导航栏和历史记录 */}
{!isTacticsPage && (
<motion.div <motion.div
animate={navBarVisibleLocal === '0' ? isHistoryVisible ? 'shrunk' : 'expanded' : 'navTween'} animate={navBarVisibleLocal === '0' ? isHistoryVisible ? 'shrunk' : 'expanded' : 'navTween'}
variants={contentVariants} variants={contentVariants}
className={`fixed right-[-12px] top-[10px] z-[49] h-auto sm:relative flex sm:h-full items-center ${isHistoryVisible && !isMobile() ? 'w-[340px]' : 'w-[90px]'} box-border`} className={`fixed right-[-12px] top-[10px] z-[49] h-auto sm:relative flex sm:h-full items-center ${isHistoryVisible && !isMobile() ? 'w-[340px]' : 'w-[90px]'} box-border`}
> >
<Navbar <Navbar
isHistoryVisible={isHistoryVisible} isHistoryVisible={isHistoryVisible}
onSetHistoryVisible={setHistoryVisible} onSetHistoryVisible={setHistoryVisible}
...@@ -92,10 +95,11 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { ...@@ -92,10 +95,11 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
</motion.div> </motion.div>
)} )}
</motion.div> </motion.div>
)}
<motion.div <motion.div
variants={contentVariants} variants={contentVariants}
animate={navBarVisibleLocal === '0' ? '' : 'mainTween'} animate={navBarVisibleLocal === '0' || isTacticsPage ? '' : 'mainTween'}
className={`${styles.layoutContent} px-[12px]`} className={`${styles.layoutContent} ${isTacticsPage ? 'px-0' : 'px-[12px]'}`}
> >
{children} {children}
</motion.div> </motion.div>
......
...@@ -28,10 +28,10 @@ export const ChatWelcome: React.FC<ChatWelcomeProps> = ({ toolName: _toolName }) ...@@ -28,10 +28,10 @@ export const ChatWelcome: React.FC<ChatWelcomeProps> = ({ toolName: _toolName })
return ( return (
<div className="chatWelcomeContainer w-full"> <div className="chatWelcomeContainer w-full">
<div className="h-[20px] sm:h-[32px] w-full"></div> <div className="h-[20px] sm:h-[32px] w-full"></div>
<div className="flex"> <div className="flex items-start">
<Avatar className="mr-[12px] hidden sm:block flex-shrink-0" src={viteOutputObj === 'inner' ? AIIcon : AvatarBot} /> <Avatar className="mr-[12px] flex-shrink-0" src={viteOutputObj === 'inner' ? AIIcon : AvatarBot} />
<motion.div <motion.div
className="sm:ml-[20px] rounded-[20px] box-border px-[16px] py-[16px] sm:px-[24px] sm:py-[20px]" className="rounded-[20px] box-border px-[16px] py-[16px] sm:px-[24px] sm:py-[20px]"
style={{ background: '#F7FAFD' }} style={{ background: '#F7FAFD' }}
> >
<div className="content"> <div className="content">
......
// 问答功能独立聊天页
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useParams } from 'react-router-dom'
import { Button } from '@heroui/react'
import { motion } from 'framer-motion'
import { useScroll } from 'ahooks'
import styles from '../Chat/Chat.module.less'
import { processApiResponse } from '../Chat/helper'
import { ChatWelcome } from '../Chat/components/ChatWelcome'
import { ChatItemUser } from '../Chat/components/ChatItem/ChatItemUser'
import { ChatAnswerBox } from '../Chat/components/ChatItem/ChatAnswerBox'
import { ChatEditor } from '@/components/ChatEditor'
import type { ChatRecord } from '@/types/chat'
import { fetchTacticsQaRecordPage } from '@/api/tactics'
import { fetchCheckTokenApi, fetchStreamResponse } from '@/api/chat'
import { clearTacticsShouldSendQuestion } from '@/store/tacticsSlice'
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 TacticsChat: React.FC = () => {
const { id } = useParams<{ id: string }>()
const location = useLocation()
const [isLoading, setIsLoading] = useState(false)
const [allItems, setAllItems] = useState<ChatRecord[]>([])
const dispatch = useAppDispatch()
const { shouldSendQuestion: shouldSendQuestionFromState } = useAppSelector((state: RootState) => state.tactics)
// 优先从 location.state 获取,其次从 Redux state 获取
const shouldSendQuestion = (location.state as { shouldSendQuestion?: string } | null)?.shouldSendQuestion || shouldSendQuestionFromState
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)
/** 处理正常stream的数据 */
const handleStreamMesageData = (msg: any, question: string) => {
setAllItems((prevItems) => {
const newItems = [...prevItems]
const lastIndex = newItems.length - 1
if (lastIndex >= 0) {
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) => {
setAllItems((prevItems) => {
const newItems = [...prevItems]
const lastIndex = newItems.length - 1
if (lastIndex >= 0) {
newItems[lastIndex] = {
...newItems[lastIndex],
question,
answerList: [
{
...msg.content.data,
isShow: false,
isChatMaxCount: true,
endAnswerFlag: true,
answer: '已达上限',
},
],
}
}
return newItems
})
}
/** 提交问题 */
const handleSubmitQuestion = async (question: string) => {
// 停止之前的请求
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,
},
(msg) => {
// 检查是否已被取消
if (abortControllerRef.current?.signal.aborted) {
return
}
// 处理错误
if (msg?.type === 'ERROR') {
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') {
dispatch(setIsAsking(false))
if (isNew) {
setTimeout(() => {
// 可以在这里刷新会话列表
}, 2000)
}
}
},
abortControllerRef.current.signal,
)
}
/** 获取qa记录 */
const getUserQaRecordPage = useCallback(async (conversationId: string) => {
setIsLoading(true)
try {
const res = await fetchTacticsQaRecordPage(conversationId)
const qaRecords = res.data || []
// 始终添加 system 角色作为欢迎语
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)
}
catch {
// 如果获取失败,至少显示欢迎语
setAllItems([{ role: 'system' } as ChatRecord])
}
finally {
setIsLoading(false)
}
}, [])
/** 点击滚动到底部 */
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)
}
else {
// 如果没有 id,显示欢迎语
setAllItems([{ role: 'system' } as ChatRecord])
setIsLoading(false)
}
}, [id, getUserQaRecordPage, dispatch])
// 处理shouldSendQuestion的变化 - 自动发送问题
useEffect(() => {
if (
shouldSendQuestion
&& currentIdRef.current
&& !isLoading
&& shouldSendQuestion !== lastSentQuestionRef.current
) {
lastSentQuestionRef.current = shouldSendQuestion
dispatch(clearTacticsShouldSendQuestion())
setTimeout(() => {
handleSubmitQuestion(shouldSendQuestion)
}, 100)
}
}, [shouldSendQuestion, isLoading, dispatch])
return (
<div
className={styles.scrollView}
style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
width: '420px',
marginLeft: 'auto',
marginRight: 0,
position: 'fixed',
right: 0,
top: 0,
backgroundColor: '#FFFFFF',
zIndex: 1000,
}}
>
<div className={`${styles.chatPage} relative flex flex-col h-full`}>
<div className={`${styles.content} flex-1 overflow-hidden flex flex-col`}>
{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 flex-1 overflow-y-auto px-[16px]`}
>
<div className={`${styles.inter} py-[24px]`}>
{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="问答功能" />}
{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-[16px] mx-auto iptContainer w-full flex-shrink-0 pb-[18px] pt-[12px] bg-white border-t border-gray-100">
<div className="relative">
<div className="absolute left-1/2 ml-[-20px] top-[-45px]">
<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={handleSubmitQuestion}
placeholders={[]}
hideTools
/>
<div className="w-full text-center mt-[12px] text-[#3333334d] text-[12px]">
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
</div>
</div>
</div>
</div>
</div>
)
}
// 问答功能独立首页
import type React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocalStorageState } from 'ahooks'
import styles from '../Home/Home.module.less'
import { ChatWelcome } from '../Chat/components/ChatWelcome'
import { clearTacticsNavigationFlag, createTacticsConversation, fetchTacticsConversations } from '@/store/tacticsSlice'
import { useAppDispatch, useAppSelector } from '@/store/hook'
import type { RootState } from '@/store'
import { fetchLoginByToken, fetchLoginByUid } from '@/api/common'
import { getUserRolesFromRoute } from '@/lib/utils'
import { ChatEditor } from '@/components/ChatEditor'
import { RECOMMEND_QUESTIONS_OTHER } from '@/config/recommendQuestion'
export const TacticsHome: React.FC = () => {
const viteOutputObj = import.meta.env.VITE_OUTPUT_OBJ || 'open'
const dispatch = useAppDispatch()
const location = useLocation()
const navigate = useNavigate()
const hasFetched = useRef(false)
const { shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion } = useAppSelector((state: RootState) => state.tactics)
const [token, setToken] = useLocalStorageState<string | undefined>('__TOKEN__', {
defaultValue: '',
})
const initTacticsConversation = () => {
const fromCollect = location.state?.fromCollect
// 只有在访问问答首页时才创建新对话
if (!fromCollect && location.pathname === '/tactics') {
dispatch(
createTacticsConversation({
conversationData: {},
shouldNavigate: true,
shouldSendQuestion: '',
}),
)
}
// 清除状态以避免下次影响
if (location.state?.fromCollect) {
window.history.replaceState({}, document.title, window.location.pathname)
}
}
// 处理创建对话并跳转(用于输入框提交)
const handleCreateConversation = useCallback((question: string) => {
dispatch(
createTacticsConversation({
conversationData: {},
shouldNavigate: true,
shouldSendQuestion: question,
}),
)
}, [dispatch])
// 监听导航到新对话
useEffect(() => {
if (shouldNavigateToNewConversation && currentConversationId) {
const url = `/tactics/chat/${currentConversationId}`
navigate(url, {
state: {
shouldSendQuestion,
},
})
dispatch(clearTacticsNavigationFlag())
}
}, [shouldNavigateToNewConversation, currentConversationId, shouldSendQuestion, navigate, dispatch])
const login = useCallback(async () => {
if (hasFetched.current) {
return
}
hasFetched.current = true
const url = new URL(window.location.href)
const searchParams = new URLSearchParams(url.search)
const _loginCode = searchParams.get('loginCode')
let res = {} as any
if (viteOutputObj === 'inner') {
if (_loginCode) {
res = await fetchLoginByToken(_loginCode)
if (res.data) {
setToken(res.data.token)
window.dispatchEvent(
new StorageEvent('storage', {
key: '__TOKEN__',
oldValue: token,
newValue: res.data.token,
url: window.location.href,
storageArea: localStorage,
}),
)
initTacticsConversation()
dispatch(fetchTacticsConversations())
}
}
else {
initTacticsConversation()
dispatch(fetchTacticsConversations())
}
}
else {
res = await fetchLoginByUid('123123')
if (res.data) {
setToken(res.data.token)
window.dispatchEvent(
new StorageEvent('storage', {
key: '__TOKEN__',
oldValue: token,
newValue: res.data.token,
url: window.location.href,
storageArea: localStorage,
}),
)
initTacticsConversation()
dispatch(fetchTacticsConversations())
}
}
}, [setToken, dispatch, token])
// 监听路由参数变化
useEffect(() => {
getUserRolesFromRoute()
}, [location.search])
useEffect(() => {
login()
}, [])
return (
<div
className={styles.homePage}
style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
width: '420px',
marginLeft: 'auto',
marginRight: 0,
position: 'fixed',
right: 0,
top: 0,
backgroundColor: '#FFFFFF',
zIndex: 1000,
}}
>
<div className="h-full w-full flex flex-col">
{/* 主要内容区域 - 全屏显示 */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* 欢迎语区域 */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-[16px] pt-[24px]">
<ChatWelcome />
</div>
{/* 底部输入框 */}
<div className="box-border px-[16px] pb-[18px] pt-[12px] bg-white border-t border-gray-100">
<ChatEditor
showContentTips
onSubmit={handleCreateConversation}
placeholders={RECOMMEND_QUESTIONS_OTHER}
hideTools
/>
<div className="w-full text-center mt-[12px] text-[#3333334d] text-[12px]">
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
</div>
</div>
</div>
</div>
</div>
)
}
export { TacticsHome } from './TacticsHome'
export { TacticsChat } from './TacticsChat'
...@@ -11,6 +11,10 @@ import { type WithAuthProps, withAuth } from '@/auth/withAuth' ...@@ -11,6 +11,10 @@ import { type WithAuthProps, withAuth } from '@/auth/withAuth'
import { useAppDispatch } from '@/store/hook' import { useAppDispatch } from '@/store/hook'
import { createConversation } from '@/store/conversationSlice' import { createConversation } from '@/store/conversationSlice'
interface WelcomeWordProps {
onCreateConversation?: () => void // 自定义创建对话函数
}
const BotEye: React.FC = () => { const BotEye: React.FC = () => {
const controls = useAnimation() const controls = useAnimation()
...@@ -55,10 +59,14 @@ const BotAnimateBox: React.FC = () => { ...@@ -55,10 +59,14 @@ const BotAnimateBox: React.FC = () => {
) )
} }
const WelcomeWordBase: React.FC<WithAuthProps> = ({ checkAuth }) => { const WelcomeWordBase: React.FC<WithAuthProps & WelcomeWordProps> = ({ checkAuth, onCreateConversation }) => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const handleCreateConversation = () => { const handleCreateConversation = () => {
if (onCreateConversation) {
onCreateConversation()
return
}
dispatch(createConversation({ dispatch(createConversation({
conversationData: {}, conversationData: {},
shouldNavigate: true, shouldNavigate: true,
......
...@@ -5,6 +5,7 @@ import { Chat } from '../pages/Chat' ...@@ -5,6 +5,7 @@ import { Chat } from '../pages/Chat'
import { Collect } from '../pages/Collect' import { Collect } from '../pages/Collect'
import { Tools } from '../pages/Tools' import { Tools } from '../pages/Tools'
import { Protocol } from '../pages/Protocol' import { Protocol } from '../pages/Protocol'
import { TacticsChat, TacticsHome } from '../pages/ChatTactics'
import { withRouteChangeHandler } from './RouteChangeHandler' import { withRouteChangeHandler } from './RouteChangeHandler'
const AppRoutesComponent: React.FC = () => { const AppRoutesComponent: React.FC = () => {
...@@ -14,6 +15,9 @@ const AppRoutesComponent: React.FC = () => { ...@@ -14,6 +15,9 @@ const AppRoutesComponent: React.FC = () => {
<Route path="/chat/:id" element={<Chat />} /> <Route path="/chat/:id" element={<Chat />} />
</Route> </Route>
<Route path="/home" element={<Home />}></Route> <Route path="/home" element={<Home />}></Route>
<Route path="/tactics" element={<TacticsHome />}>
<Route path="/tactics/chat/:id" element={<TacticsChat />} />
</Route>
<Route path="/collect" element={<Collect />} /> <Route path="/collect" element={<Collect />} />
<Route path="/tools" element={<Tools />} /> <Route path="/tools" element={<Tools />} />
<Route path="/protocol/:id" element={<Protocol />} /> <Route path="/protocol/:id" element={<Protocol />} />
......
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { clearCurrentConversation, setCurrentConversation } from '@/store/conversationSlice' import { clearCurrentConversation, setCurrentConversation } from '@/store/conversationSlice'
import { clearCurrentTacticsConversation, setCurrentTacticsConversation } from '@/store/tacticsSlice'
import { useAppDispatch, useAppSelector } from '@/store/hook' import { useAppDispatch, useAppSelector } from '@/store/hook'
import { setIsAsking } from '@/store/chatSlice' import { setIsAsking } from '@/store/chatSlice'
import type { RootState } from '@/store' import type { RootState } from '@/store'
...@@ -17,6 +18,20 @@ export function withRouteChangeHandler(WrappedComponent: React.ComponentType) { ...@@ -17,6 +18,20 @@ export function withRouteChangeHandler(WrappedComponent: React.ComponentType) {
const oldPath = beforeLocationPathName const oldPath = beforeLocationPathName
useEffect(() => { useEffect(() => {
// 处理 /home?from=tactics 重定向到 /tactics
if (location.pathname === '/home') {
const searchParams = new URLSearchParams(location.search)
const from = searchParams.get('from')
if (from === 'tactics') {
// 重定向到 /tactics,保留其他查询参数
const newSearchParams = new URLSearchParams(location.search)
newSearchParams.delete('from')
const newSearch = newSearchParams.toString()
navigate(`/tactics${newSearch ? `?${newSearch}` : ''}`, { replace: true })
return
}
}
if (isAsking && newPath !== oldPath && oldPath !== '') { if (isAsking && newPath !== oldPath && oldPath !== '') {
dispatch(setIsAsking(false)) dispatch(setIsAsking(false))
} }
...@@ -46,9 +61,33 @@ export function withRouteChangeHandler(WrappedComponent: React.ComponentType) { ...@@ -46,9 +61,33 @@ export function withRouteChangeHandler(WrappedComponent: React.ComponentType) {
dispatch(setCurrentConversation(conversationId)) dispatch(setCurrentConversation(conversationId))
} }
// 处理问答功能的聊天路由
else if (location.pathname.startsWith('/tactics/chat/')) {
const conversationId = location.pathname.split('/')[3]
const tokenStr = window.localStorage.getItem('__TOKEN__') || '""'
let token = ''
try {
token = JSON.parse(tokenStr)
}
catch {
navigate('/tactics')
return
}
if (!token) {
navigate('/tactics')
return
}
dispatch(setCurrentTacticsConversation(conversationId))
}
else if (location.pathname === '/tactics') {
dispatch(clearCurrentTacticsConversation())
}
// 这里可以添加其他路由相关的逻辑 // 这里可以添加其他路由相关的逻辑
beforeLocationPathName = newPath beforeLocationPathName = newPath
}, [location, dispatch]) }, [location, dispatch, navigate])
return <WrappedComponent {...props} /> return <WrappedComponent {...props} />
} }
......
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit'
import conversationReducer from './conversationSlice' import conversationReducer from './conversationSlice'
import chatReducer from './chatSlice' import chatReducer from './chatSlice'
import tacticsReducer from './tacticsSlice'
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
conversation: conversationReducer, conversation: conversationReducer,
chat: chatReducer, chat: chatReducer,
tactics: tacticsReducer,
}, },
}) })
// 为了在TypeScript中使用,我们导出这些类型 // 为了在TypeScript中使用,我们导出这些类型
......
// 问答功能独立的状态管理
import type { PayloadAction } from '@reduxjs/toolkit'
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import type { TacticsConversation, TacticsConversationState } from '@/types/tactics'
import { fetchCreateTacticsConversation, fetchDeleteTacticsConversation, fetchTacticsConversationPage } from '@/api/tactics'
const initialState: TacticsConversationState = {
conversations: [],
currentConversationId: null,
isLoading: false,
error: null,
shouldNavigateToNewConversation: false,
shouldSendQuestion: '',
}
// 处理会话数据(简化版,可根据实际需求调整)
function processTacticsConversationData(records: any[]): TacticsConversation[] {
return records.map(record => ({
id: record.conversationId || record.id,
title: record.title || record.conversationTitle,
createdAt: record.createTime || record.createdAt,
updatedAt: record.updateTime || record.updatedAt,
}))
}
export const fetchTacticsConversations = createAsyncThunk(
'tactics/fetchTacticsConversations',
async (_, { rejectWithValue }) => {
try {
const response = await fetchTacticsConversationPage({
pageNum: 1,
pageSize: 100,
})
const records = response.data?.records || []
return processTacticsConversationData(records)
}
catch (error) {
console.error('Failed to fetch tactics conversations:', error)
return rejectWithValue('Failed to fetch tactics conversations')
}
},
)
export const deleteTacticsConversations = createAsyncThunk<
boolean,
string[],
{ rejectValue: boolean }
>(
'tactics/deleteTacticsConversations',
async (conversationIds, { dispatch, rejectWithValue }) => {
try {
await fetchDeleteTacticsConversation(conversationIds)
await dispatch(fetchTacticsConversations())
return true
}
catch (error) {
console.error('Failed to delete tactics conversations:', error)
return rejectWithValue(false)
}
},
)
export const createTacticsConversation = createAsyncThunk<
{ conversation: TacticsConversation, shouldNavigate: boolean, shouldSendQuestion: string },
{ conversationData: Partial<TacticsConversation>, shouldNavigate: boolean, shouldSendQuestion: string }
>(
'tactics/createTacticsConversation',
async ({ conversationData, shouldNavigate, shouldSendQuestion }, { dispatch }) => {
const response = await fetchCreateTacticsConversation(conversationData)
const newConversation = response.data
await dispatch(fetchTacticsConversations())
return {
conversation: {
id: newConversation.conversationId || newConversation.id,
title: newConversation.title || newConversation.conversationTitle,
createdAt: newConversation.createTime || newConversation.createdAt,
updatedAt: newConversation.updateTime || newConversation.updatedAt,
},
shouldNavigate,
shouldSendQuestion,
}
},
)
const tacticsSlice = createSlice({
name: 'tactics',
initialState,
reducers: {
setCurrentTacticsConversation: (state, action: PayloadAction<string>) => {
state.currentConversationId = action.payload
},
clearCurrentTacticsConversation: (state) => {
state.currentConversationId = null
},
clearTacticsShouldSendQuestion: (state) => {
state.shouldSendQuestion = ''
},
setTacticsShouldSendQuestion: (state, action: PayloadAction<string>) => {
state.shouldSendQuestion = action.payload
},
addTacticsConversation: (state, action: PayloadAction<TacticsConversation>) => {
state.conversations.unshift(action.payload)
},
clearTacticsNavigationFlag: (state) => {
state.shouldNavigateToNewConversation = false
},
removeTacticsConversation: (state, action: PayloadAction<string>) => {
state.conversations = state.conversations.filter(conv => conv.id !== action.payload)
if (state.currentConversationId === action.payload) {
state.currentConversationId = state.conversations[0]?.id || null
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTacticsConversations.pending, (state) => {
state.isLoading = true
state.error = null
})
.addCase(fetchTacticsConversations.fulfilled, (state, action) => {
state.isLoading = false
state.conversations = action.payload
})
.addCase(fetchTacticsConversations.rejected, (state, action) => {
state.isLoading = false
state.error = action.payload as string
})
.addCase(createTacticsConversation.pending, (state) => {
state.isLoading = true
state.error = null
})
.addCase(createTacticsConversation.fulfilled, (state, action) => {
state.isLoading = false
if (action.payload.shouldNavigate) {
state.currentConversationId = action.payload.conversation.id
}
state.shouldNavigateToNewConversation = action.payload.shouldNavigate
state.shouldSendQuestion = action.payload.shouldSendQuestion
})
.addCase(createTacticsConversation.rejected, (state, action) => {
state.isLoading = false
state.error = action.error.message || 'Failed to create tactics conversation'
})
.addCase(deleteTacticsConversations.fulfilled, () => {
})
.addCase(deleteTacticsConversations.rejected, () => {
})
},
})
export const {
setCurrentTacticsConversation,
clearCurrentTacticsConversation,
clearTacticsNavigationFlag,
clearTacticsShouldSendQuestion,
setTacticsShouldSendQuestion,
} = tacticsSlice.actions
export default tacticsSlice.reducer
// 问答功能独立的类型定义
export interface TacticsConversation {
id: string
title?: string
createdAt?: string
updatedAt?: string
}
export interface TacticsConversationState {
conversations: TacticsConversation[]
currentConversationId: string | null
isLoading: boolean
error: string | null
shouldNavigateToNewConversation: boolean
shouldSendQuestion: string
}
export interface TacticsChatRecord {
role: 'system' | 'user' | 'ai'
question?: string
answer?: string
groupId?: string
recordId?: string
conversationId?: string
qaTime?: string
}
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