Commit 1eb20b85 by HoMeTown

feat: streamAnswer

parent bb54d5a2
import { http } from '@/utils/request'
/**
* 查询token合法
* @params
*/
export function fetchCheckTokenApi() {
return http.post('/user/api/user_center/mobile/v1/check_token', {})
}
export function fetchStreamResponse(url: string, body: Record<string, any>, onMessage: (msg: any) => void) {
body.stream = true
const decoder = new TextDecoder('utf-8')
let buffer = ''
let dataMsgBuffer = ''
function processMessage(reader: any) {
reader.read().then((content: any) => {
buffer += decoder.decode(content.value, { stream: !content.done })
const lines = buffer.split('\n')
buffer = lines.pop() as string
lines.forEach((line) => {
if (line === '') { // 读取到空行,一个数据块发送完成
onMessage({
type: 'DATA',
content: JSON.parse(dataMsgBuffer),
})
dataMsgBuffer = ''
return
}
const [type] = line.split(':', 1)
const content = line.substring(type.length + 1)
if (type === 'data') { // 数据块没有收到空行之前放入buffer中
dataMsgBuffer += content.trim()
}
else if (type === '' && content !== '') { // 服务端发送的注释,用于保证链接不断开
onMessage({
type: 'COMMENT',
content: content.trim(),
})
}
else {
onMessage({
type,
content: content.trim(),
})
}
})
if (!content.done) {
processMessage(reader)
}
else {
onMessage({
type: 'END',
})
}
})
}
fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-Token': JSON.parse(window.localStorage.getItem('__TOKEN__') as string) || '',
},
method: 'POST',
body: JSON.stringify(body),
})
.then(response => response.body?.getReader())
.then(reader => processMessage(reader))
.catch(error => onMessage({
type: 'ERROR',
content: error,
}))
}
import React from 'react'
import { useDispatch } from 'react-redux'
import { useParams } from 'react-router-dom'
import styles from './Chat.module.less'
import { ChatSlogan } from './components/ChatSlogan'
import { ChatContent } from './components/ChatContent'
import { ChatMaskBar } from './components/ChatMaskBar'
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 { fetchCheckTokenApi, fetchStreamResponse } from '@/api/chat'
export const Chat: React.FC = () => {
const dispatch = useDispatch()
const { id } = useParams<{ id: string }>()
const handleQuestion = async (question: string) => {
// 添加用户问题
const userRecord: ChatRecord = {
type: 'question',
originalData: {
question,
answerList: [],
},
}
dispatch(addRecord(userRecord))
setIsLoading(true)
// 添加空的AI回答
const aiMessage: ChatRecord = {
type: 'streamAnswer',
originalData: { answerList: [{ answer: '', attachmentList: [] }] },
}
dispatch(addRecord(aiMessage))
await fetchCheckTokenApi()
let fetchUrl = `/conversation/api/conversation/mobile/v1/submit_question_stream`
const proxy = import.meta.env.MODE === 'dev' ? '/api' : '/sdream-api'
fetchUrl = proxy + fetchUrl
fetchStreamResponse(
fetchUrl,
{
question,
conversationId: id,
stream: true,
},
(msg) => {
if (msg.type === 'DATA') {
dispatch(updateLastAnswer(msg.content.data))
}
},
)
}
return (
<div className={`${styles.chatPage} relative`}>
<ChatSlogan />
......@@ -15,7 +64,7 @@ export const Chat: React.FC = () => {
<ChatContent />
</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={handleQuestion} placeholders={RECOMMEND_QUESTIONS_OTHER} />
<div className="w-full text-center mt-[20px] text-[#3333334d] text-[12px]">
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
</div>
......
......@@ -3,15 +3,19 @@ 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 { records, isLoading, error } = useAppSelector(state => state.chat)
const { isLoading, error } = useAppSelector(state => state.chat)
const records = useSelector((state: RootState) => state.chat.records)
const allItems: ChatRecord[] = [{ type: 'system' }, ...records]
......
import { ChatWelcome } from '../ChatWelcome'
import { ChatItemBot } from './ChatItemBot'
import { ChatItemStream } from './ChatItemStream'
import { ChatItemUser } from './ChatItemUser'
import type { ChatRecord } from '@/types/chat'
......@@ -10,13 +11,10 @@ interface ChatItemProps {
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} /> : null
}
{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>
)
}
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'
......@@ -40,7 +41,31 @@ export const fetchChatRecords = createAsyncThunk(
const chatSlice = createSlice({
name: 'chat',
initialState,
reducers: {},
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) => {
......@@ -58,4 +83,5 @@ const chatSlice = createSlice({
},
})
export const { addRecord, setIsLoading, updateLastAnswer } = chatSlice.actions
export default chatSlice.reducer
......@@ -6,27 +6,30 @@ export interface Attachment {
export interface Answer {
answer: string
collectionFlag: boolean
feedbackStatus: string
groupId: string
question: string
recordId: string
terminateFlag: boolean
toolName: string
collectionFlag?: boolean
feedbackStatus?: string
groupId?: string
question?: string
recordId?: string
terminateFlag?: boolean
toolName?: string
attachmentList: Attachment[]
}
export interface OriginalRecord {
groupId: string
question: string
answer?: string
groupId?: string
question?: string
answerList: Answer[]
productCode: string
qaTime: string
productCode?: string
qaTime?: string
}
export type ChatRecordType = 'system' | 'question' | 'answer' | 'streamAnswer'
export interface ChatRecord {
type: 'question' | 'answer' | 'system'
originalData?: OriginalRecord
type: ChatRecordType
originalData: OriginalRecord
}
export interface ChatState {
......
......@@ -91,6 +91,11 @@ export const http = {
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
},
stream<T = any>(url: string, data: T) {
return service.post(url, data, {
responseType: 'stream',
})
},
}
/* 导出 axios 实例 */
......
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