Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
S
sdream-ai-fe
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
侯明涛
sdream-ai-fe
Commits
1eb20b85
Commit
1eb20b85
authored
Aug 12, 2024
by
HoMeTown
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: streamAnswer
parent
bb54d5a2
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
231 additions
and
23 deletions
+231
-23
src/api/chat.ts
+72
-0
src/pages/Chat/Chat.tsx
+50
-1
src/pages/Chat/components/ChatContent/index.tsx
+5
-1
src/pages/Chat/components/ChatItem/ChatItem.tsx
+5
-7
src/pages/Chat/components/ChatItem/ChatItemStream.tsx
+51
-0
src/store/chatSlice.ts
+27
-1
src/types/chat.ts
+16
-13
src/utils/request.ts
+5
-0
No files found.
src/api/chat.ts
0 → 100644
View file @
1eb20b85
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
,
}))
}
src/pages/Chat/Chat.tsx
View file @
1eb20b85
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
>
...
...
src/pages/Chat/components/ChatContent/index.tsx
View file @
1eb20b85
...
...
@@ -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
]
...
...
src/pages/Chat/components/ChatItem/ChatItem.tsx
View file @
1eb20b85
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
>
)
}
src/pages/Chat/components/ChatItem/ChatItemStream.tsx
0 → 100644
View file @
1eb20b85
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
>
)
}
src/store/chatSlice.ts
View file @
1eb20b85
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
src/types/chat.ts
View file @
1eb20b85
...
...
@@ -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
{
...
...
src/utils/request.ts
View file @
1eb20b85
...
...
@@ -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 实例 */
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment