Commit 752d01fe by HoMeTown

feat: 提问

parent 1eb20b85
......@@ -23,7 +23,11 @@ const NavbarBase: React.FC<NavbarProps & WithAuthProps> = ({ isHistoryVisible, c
const { currentConversationId, shouldNavigateToNewConversation } = useAppSelector(state => state.conversation)
const handleCreateConversation = () => {
dispatch(createConversation({}))
dispatch(createConversation({
conversationData: {},
shouldNavigate: true,
shouldSendQuestion: '',
}))
}
const handleClick = (type: string | undefined) => {
......
import React from 'react'
import { useDispatch } from 'react-redux'
import React, { useCallback, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Spinner } from '@nextui-org/react'
import { Virtuoso } from 'react-virtuoso'
import { motion } from 'framer-motion'
import { useDispatch, useSelector } from 'react-redux'
import styles from './Chat.module.less'
import { ChatSlogan } from './components/ChatSlogan'
import { ChatContent } from './components/ChatContent'
import { ChatMaskBar } from './components/ChatMaskBar'
import { processApiResponse } from './helper'
import { ChatWelcome } from './components/ChatWelcome'
import { ChatItemUser } from './components/ChatItem/ChatItemUser'
import { ChatAnswerBox } from './components/ChatItem/ChatAnswerBox'
import { RECOMMEND_QUESTIONS_OTHER } from '@/config/recommendQuestion'
import { ChatEditor } from '@/components/ChatEditor'
import type { ChatRecord } from '@/types/chat'
import { addRecord, setIsLoading, updateLastAnswer } from '@/store/chatSlice'
import { fetchUserQaRecordPage } from '@/api/conversation'
import { fetchCheckTokenApi, fetchStreamResponse } from '@/api/chat'
import { clearShouldSendQuestion } from '@/store/conversationSlice'
import type { RootState } from '@/store' // 假设你的 store 文件导出了 RootState 类型
export const Chat: React.FC = () => {
const dispatch = useDispatch()
const { id } = useParams<{ id: string }>()
const [isLoading, setIsLoading] = useState(false)
const [allItems, setAllItems] = useState<ChatRecord[]>([])
const dispatch = useDispatch()
const shouldSendQuestion = useSelector((state: RootState) => state.conversation.shouldSendQuestion)
const handleQuestion = async (question: string) => {
// 添加用户问题
const userRecord: ChatRecord = {
type: 'question',
originalData: {
const handleSubmitQuestion = useCallback(async (question: string) => {
// 添加用户提问的问题
setAllItems(prevItems => [
...prevItems,
{
role: 'user',
question,
answerList: [],
},
}
dispatch(addRecord(userRecord))
setIsLoading(true)
// 添加空的AI回答
const aiMessage: ChatRecord = {
type: 'streamAnswer',
originalData: { answerList: [{ answer: '', attachmentList: [] }] },
}
dispatch(addRecord(aiMessage))
} as ChatRecord,
])
// 检查token
await fetchCheckTokenApi()
// 添加一条空的ai问题
setAllItems(prevItems => [
...prevItems,
{
role: 'ai',
answerList: [{}],
} as ChatRecord,
])
let fetchUrl = `/conversation/api/conversation/mobile/v1/submit_question_stream`
const proxy = import.meta.env.MODE === 'dev' ? '/api' : '/sdream-api'
fetchUrl = proxy + fetchUrl
......@@ -50,21 +60,87 @@ export const Chat: React.FC = () => {
},
(msg) => {
if (msg.type === 'DATA') {
dispatch(updateLastAnswer(msg.content.data))
setAllItems((prevItems) => {
const newItems = [...prevItems] // 创建数组的浅拷贝
const lastIndex = newItems.length - 1
if (lastIndex >= 0) {
// 创建最后一项的新对象,合并现有数据和新的 answer
newItems[lastIndex] = {
...newItems[lastIndex],
...msg.content.data,
answer: (newItems[lastIndex].answer || '') + msg.content.data.answer,
}
}
return newItems
})
}
},
)
}
}, [])
const getUserQaRecordPage = useCallback(async (conversationId: string) => {
setIsLoading(true)
try {
const res = await fetchUserQaRecordPage(conversationId)
const messages = [{ role: 'system' } as ChatRecord, ...processApiResponse(res.data)]
setAllItems(messages) // 假设 API 返回的数据结构符合 ChatRecord[]
if (shouldSendQuestion) {
handleSubmitQuestion(shouldSendQuestion)
dispatch(clearShouldSendQuestion())
}
}
catch (error) {
console.error('Failed to fetch chat records:', error)
// 可以在这里添加错误处理逻辑
}
finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
if (id) {
getUserQaRecordPage(id)
}
}, [id, getUserQaRecordPage])
return (
<div className={`${styles.chatPage} relative`}>
<ChatSlogan />
<ChatMaskBar />
<div className={styles.content}>
<ChatContent />
{isLoading && <div className="w-full h-full flex justify-center"><Spinner /></div>}
{!isLoading && (
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
x: { type: 'spring', stiffness: 50 },
scale: { type: 'spring', stiffness: 50 },
opacity: { duration: 0.7 },
}}
className="w-full h-full mx-auto"
>
<Virtuoso
style={{ height: '100%' }} // 设置一个固定高度或使用动态高度
data={allItems}
itemContent={(index, record) => (
<div className="chatItem max-w-[1000px] mx-auto">
{record.role === 'system' && <ChatWelcome />}
{record.role === 'user' && <ChatItemUser record={record} />}
{record.role === 'ai' && <ChatAnswerBox isLastAnswer={index === allItems.length - 1} showIndex={0} record={record} index={index} />}
</div>
)}
initialTopMostItemIndex={allItems.length - 1} // 初始滚动到底部
followOutput="smooth" // 新消息时平滑滚动到底部
/>
</motion.div>
)}
</div>
<div className="box-border px-[0] mx-auto iptContainer w-full max-w-[1000px] flex-shrink-0 sm:px-0 pb-[18px]">
<ChatEditor onSubmit={handleQuestion} placeholders={RECOMMEND_QUESTIONS_OTHER} />
<ChatEditor onSubmit={handleSubmitQuestion} placeholders={RECOMMEND_QUESTIONS_OTHER} />
<div className="w-full text-center mt-[20px] text-[#3333334d] text-[12px]">
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
</div>
......
import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Spinner } from '@nextui-org/react'
import { Virtuoso } from 'react-virtuoso'
import { motion } from 'framer-motion'
import { useSelector } from 'react-redux'
import { ChatItem } from '../ChatItem'
import { useAppDispatch, useAppSelector } from '@/store/hook'
import { fetchChatRecords } from '@/store/chatSlice'
import type { ChatRecord } from '@/types/chat'
import type { RootState } from '@/store'
export const ChatContent: React.FC = () => {
const { id } = useParams<{ id: string }>()
const dispatch = useAppDispatch()
const { isLoading, error } = useAppSelector(state => state.chat)
const records = useSelector((state: RootState) => state.chat.records)
const allItems: ChatRecord[] = [{ type: 'system' }, ...records]
useEffect(() => {
if (id) {
dispatch(fetchChatRecords(id))
}
}, [id, dispatch])
if (isLoading)
return <div className="w-full h-full flex justify-center"><Spinner /></div>
if (error) {
return (
<div>
Error:
{error}
</div>
)
}
return (
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
x: { type: 'spring', stiffness: 50 },
scale: { type: 'spring', stiffness: 50 },
opacity: { duration: 0.7 },
}}
className="w-full h-full mx-auto"
>
<Virtuoso
style={{ height: '100%' }} // 设置一个固定高度或使用动态高度
data={allItems}
itemContent={(index, record) => (
<ChatItem record={record} key={record.originalData?.groupId} />
)}
initialTopMostItemIndex={allItems.length - 1} // 初始滚动到底部
followOutput="smooth" // 新消息时平滑滚动到底部
/>
</motion.div>
)
}
......@@ -4,33 +4,49 @@ import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import { formatMarkdown } from './markdownFormatter'
import AvatarBot from '@/assets/avatarBot.png'
import type { ChatRecord } from '@/types/chat'
import AvatarBot from '@/assets/avatarBot.png'
interface ChatItemBotProps {
interface ChatAnswerBoxProps {
record: ChatRecord
showIndex: number
isLastAnswer: boolean
index: number
}
export const ChatItemBot: React.FC<ChatItemBotProps> = ({ record }) => {
export const ChatAnswerBox: React.FC<ChatAnswerBoxProps> = ({ record, showIndex }) => {
return (
<div className="chatItemBotContainer w-full">
<div className="flex">
<Avatar className="flex-shrink-0" src={AvatarBot} />
<motion.div
className="ml-[20px] bg-white rounded-[20px] box-border px-[24px] py-[20px]"
>
<div className="content">
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeSanitize]}
className="markdown-content"
>
{formatMarkdown(record.originalData?.answerList[0].answer || '')}
</ReactMarkdown>
</div>
</motion.div>
<div className="w-[130px]"></div>
</div>
<div className="h-[32px] w-full"></div>
<div>
{record.answerList.map((item, index) => {
return (
index === showIndex && (
<div className="chatItemBotContainer w-full" key={`${item.recordId}-${index}`}>
<div className="flex">
<Avatar className="flex-shrink-0" src={AvatarBot} />
<motion.div
className="ml-[20px] bg-white rounded-[20px] box-border px-[24px] py-[20px]"
>
{item.answer && (
<div className="content">
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeSanitize]}
className="markdown-content"
>
{formatMarkdown(item.answer || '')}
</ReactMarkdown>
</div>
)}
{!item.answer && (
<span>loading</span>
)}
</motion.div>
<div className="w-[130px]"></div>
</div>
<div className="h-[32px] w-full"></div>
</div>
)
)
})}
</div>
)
}
import { ChatWelcome } from '../ChatWelcome'
import { ChatItemBot } from './ChatItemBot'
import { ChatItemStream } from './ChatItemStream'
import { ChatItemUser } from './ChatItemUser'
import type { ChatRecord } from '@/types/chat'
interface ChatItemProps {
record: ChatRecord
}
export const ChatItem: React.FC<ChatItemProps> = ({ record }) => {
return (
<div className="chatItem max-w-[1000px] mx-auto">
{record.type === 'system' && <ChatWelcome />}
{record.type === 'question' && <ChatItemUser record={record} />}
{record.type === 'answer' && <ChatItemBot record={record} />}
{record.type === 'streamAnswer' && <ChatItemStream record={record} />}
</div>
)
}
import React, { useEffect, useState } from 'react'
import { Avatar } from '@nextui-org/react'
import { motion } from 'framer-motion'
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import { formatMarkdown } from './markdownFormatter'
import type { ChatRecord } from '@/types/chat'
import AvatarBot from '@/assets/avatarBot.png'
interface ChatItemStreamProps {
record: ChatRecord
}
export const ChatItemStream: React.FC<ChatItemStreamProps> = ({ record }) => {
const [displayedContent, setDisplayedContent] = useState('')
const content = record.originalData?.answerList?.[0]?.answer || ''
useEffect(() => {
let i = 0
const timer = setInterval(() => {
setDisplayedContent(content.slice(0, i))
i++
if (i > content.length) {
clearInterval(timer)
}
}, 20) // 调整速度
return () => clearInterval(timer)
}, [content])
return (
<div className="chatItemBotContainer w-full">
<div className="flex">
<Avatar className="flex-shrink-0" src={AvatarBot} />
<motion.div
className="ml-[20px] bg-white rounded-[20px] box-border px-[24px] py-[20px]"
>
<div className="content">
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeSanitize]}
className="markdown-content"
>
{formatMarkdown(displayedContent)}
</ReactMarkdown>
</div>
</motion.div>
<div className="w-[130px]"></div>
</div>
<div className="h-[32px] w-full"></div>
</div>
)
}
......@@ -10,7 +10,7 @@ export const ChatItemUser: React.FC<ChatItemUserProps> = ({ record }) => {
return (
<div className="chatItemUser">
<div className="flex justify-end">
<div className="mr-[20px] bg-[#BFE9FE] rounded-[20px] box-border px-[24px] py-[20px] text-[#27353C]">{record.originalData?.question}</div>
<div className="mr-[20px] bg-[#BFE9FE] rounded-[20px] box-border px-[24px] py-[20px] text-[#27353C]">{record.question}</div>
<Avatar className="flex-shrink-0" src={AvatarUser} />
</div>
<div className="h-[32px] w-full"></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',
...record,
})
}
})
return chatRecord
}
......@@ -10,6 +10,8 @@ import HomeIcon2 from '@/assets/homeIcon2.png'
import { GradientBackground } from '@/components/GradientBackground'
import { ChatEditor } from '@/components/ChatEditor'
import { RECOMMEND_QUESTIONS_OTHER, RECOMMEND_QUESTIONS_PRODUCT } from '@/config/recommendQuestion'
import { createConversation } from '@/store/conversationSlice'
import { useAppDispatch } from '@/store/hook'
function getAnimationProps(delay: number) {
return {
......@@ -38,6 +40,15 @@ function getAnimationProps(delay: number) {
}
export const Home: React.FC = () => {
const dispatch = useAppDispatch()
const handleCreateConversation = (question: string) => {
dispatch(createConversation({
conversationData: {},
shouldNavigate: true,
shouldSendQuestion: question,
}))
}
return (
<div className={styles.homePage}>
<GradientBackground />
......@@ -64,7 +75,7 @@ export const Home: React.FC = () => {
/>
</div>
<div className="box-border px-[0] mx-auto iptContainer w-full max-w-[1000px] flex-shrink-0 sm:px-0 pb-[18px]">
<ChatEditor placeholders={RECOMMEND_QUESTIONS_OTHER} />
<ChatEditor onSubmit={handleCreateConversation} placeholders={RECOMMEND_QUESTIONS_OTHER} />
<div className="w-full text-center mt-[20px] text-[#3333334d] text-[12px]">
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
</div>
......
......@@ -59,7 +59,11 @@ const WelcomeWordBase: React.FC<WithAuthProps> = ({ checkAuth }) => {
const dispatch = useAppDispatch()
const handleCreateConversation = () => {
dispatch(createConversation({}))
dispatch(createConversation({
conversationData: {},
shouldNavigate: true,
shouldSendQuestion: '',
}))
}
const handleGo = () => {
if (checkAuth()) {
......
import type { PayloadAction } from '@reduxjs/toolkit'
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { fetchUserQaRecordPage } from '@/api/conversation'
import type { ChatRecord, ChatState, OriginalRecord } from '@/types/chat'
const initialState: ChatState = {
records: [],
isLoading: false,
error: null,
}
function processApiResponse(data: OriginalRecord[]): ChatRecord[] {
const chatRecord: ChatRecord[] = []
if (data.length === 0)
return chatRecord
data.forEach((record) => {
chatRecord.push({
type: 'question',
originalData: record,
})
if (record.answerList && record.answerList.length > 0) {
chatRecord.push({
type: 'answer',
originalData: record,
})
}
})
return chatRecord
}
export const fetchChatRecords = createAsyncThunk(
'chat/fetchRecords',
async (conversationId: string) => {
const response = await fetchUserQaRecordPage(conversationId)
return processApiResponse(response.data)
},
)
const chatSlice = createSlice({
name: 'chat',
initialState,
reducers: {
addRecord: (state, action: PayloadAction<ChatRecord>) => {
state.records.push(action.payload)
},
updateLastAnswer: (state, action: PayloadAction<OriginalRecord>) => {
const lastIndex = state.records.length - 1
if (lastIndex >= 0 && state.records[lastIndex].type === 'streamAnswer') {
state.records[lastIndex] = {
...state.records[lastIndex],
originalData: {
...state.records[lastIndex].originalData,
answerList: [
{
...state.records[lastIndex].originalData.answerList[0],
answer: state.records[lastIndex].originalData.answerList[0].answer + action.payload.answer,
},
],
},
}
}
},
setIsLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload
},
},
extraReducers: (builder) => {
builder
.addCase(fetchChatRecords.pending, (state) => {
state.isLoading = true
state.error = null
})
.addCase(fetchChatRecords.fulfilled, (state, action) => {
state.isLoading = false
state.records = action.payload
})
.addCase(fetchChatRecords.rejected, (state, action) => {
state.isLoading = false
state.error = action.error.message || 'Failed to fetch chat records'
})
},
})
export const { addRecord, setIsLoading, updateLastAnswer } = chatSlice.actions
export default chatSlice.reducer
......@@ -10,6 +10,7 @@ const initialState: ConversationState = {
isLoading: false,
error: null,
shouldNavigateToNewConversation: false,
shouldSendQuestion: '',
}
export const fetchConversations = createAsyncThunk(
......@@ -32,18 +33,18 @@ export const fetchConversations = createAsyncThunk(
)
export const createConversation = createAsyncThunk<
{ conversation: Conversation, shouldNavigate: boolean },
Partial<Conversation>,
{ conversation: Conversation, shouldNavigate: boolean, shouldSendQuestion: string },
{ conversationData: Partial<Conversation>, shouldNavigate: boolean, shouldSendQuestion: string },
{ state: { conversation: ConversationState } }
>(
'conversation/createConversation',
async (conversationData, { dispatch }) => {
async ({ conversationData, shouldNavigate, shouldSendQuestion }, { dispatch }) => {
const response = await fetchCreateConversation(conversationData)
const newConversation = response.data
dispatch(fetchConversations())
return { conversation: newConversation, shouldNavigate: true }
return { conversation: newConversation, shouldNavigate, shouldSendQuestion }
},
)
......@@ -57,6 +58,9 @@ const conversationSlice = createSlice({
clearCurrentConversation: (state) => {
state.currentConversationId = null
},
clearShouldSendQuestion: (state) => {
state.shouldSendQuestion = ''
},
addConversation: (state, action: PayloadAction<Conversation>) => {
state.conversations.unshift(action.payload)
},
......@@ -92,6 +96,7 @@ const conversationSlice = createSlice({
state.isLoading = false
state.currentConversationId = action.payload.conversation.conversationId
state.shouldNavigateToNewConversation = action.payload.shouldNavigate
state.shouldSendQuestion = action.payload.shouldSendQuestion
})
.addCase(createConversation.rejected, (state, action) => {
state.isLoading = false
......@@ -100,6 +105,6 @@ const conversationSlice = createSlice({
},
})
export const { setCurrentConversation, clearCurrentConversation, clearNavigationFlag } = conversationSlice.actions
export const { setCurrentConversation, clearCurrentConversation, clearNavigationFlag, clearShouldSendQuestion } = conversationSlice.actions
export default conversationSlice.reducer
import { configureStore } from '@reduxjs/toolkit'
import conversationReducer from './conversationSlice'
import chatReducer from './chatSlice'
export const store = configureStore({
reducer: {
conversation: conversationReducer,
chat: chatReducer,
},
})
// 为了在TypeScript中使用,我们导出这些类型
......
......@@ -5,6 +5,7 @@ export interface Attachment {
}
export interface Answer {
isShow: boolean
answer: string
collectionFlag?: boolean
feedbackStatus?: string
......@@ -25,11 +26,10 @@ export interface OriginalRecord {
qaTime?: string
}
export type ChatRecordType = 'system' | 'question' | 'answer' | 'streamAnswer'
export type ChatRecordRole = 'system' | 'user' | 'ai'
export interface ChatRecord {
type: ChatRecordType
originalData: OriginalRecord
export interface ChatRecord extends OriginalRecord {
role: ChatRecordRole
}
export interface ChatState {
......
......@@ -13,4 +13,5 @@ export interface ConversationState {
isLoading: boolean
shouldNavigateToNewConversation: boolean
error: string | null
shouldSendQuestion: 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