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
b4dc7dee
Commit
b4dc7dee
authored
Dec 12, 2025
by
Liu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat:新建会话逻辑&&重新分析&&清除记录
parent
887c7712
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
422 additions
and
141 deletions
+422
-141
src/components/TacticsChatEditor.tsx
+125
-0
src/pages/Chat/Chat.module.less
+2
-2
src/pages/ChatTactics/TacticsChat.tsx
+217
-27
src/pages/ChatTactics/TacticsHome.tsx
+58
-108
src/pages/ChatTactics/components/TacticsWelcome/index.tsx
+2
-2
src/routes/AppRoutes.tsx
+1
-2
src/routes/RouteChangeHandler.tsx
+17
-0
No files found.
src/components/TacticsChatEditor.tsx
0 → 100644
View file @
b4dc7dee
import
React
,
{
useRef
,
useState
}
from
'react'
import
{
Button
,
Tooltip
}
from
'@heroui/react'
import
SendIcon
from
'@/assets/svg/send.svg?react'
import
{
type
WithAuthProps
,
withAuth
}
from
'@/auth/withAuth'
import
{
useAppSelector
}
from
'@/store/hook'
import
type
{
RootState
}
from
'@/store'
interface
TacticsChatEditorProps
{
onSubmit
?:
(
value
:
string
)
=>
void
onChange
?:
(
value
:
string
)
=>
void
placeholders
?:
string
[]
initialValue
?:
string
}
const
defaultPlaceholders
:
string
[]
=
[]
const
TacticsChatEditorBase
:
React
.
FC
<
TacticsChatEditorProps
&
WithAuthProps
>
=
({
onSubmit
,
onChange
,
placeholders
=
defaultPlaceholders
,
initialValue
=
''
,
checkAuth
,
})
=>
{
const
[
content
,
setContent
]
=
useState
(
initialValue
)
const
textareaRef
=
useRef
<
HTMLTextAreaElement
>
(
null
)
const
isAsking
=
useAppSelector
((
state
:
RootState
)
=>
state
.
chat
.
isAsking
)
const
[
placeholderIndex
]
=
useState
(
0
)
const
currentPlaceholder
=
placeholders
[
placeholderIndex
]
||
'输入您想问的问题吧'
const
adjustHeight
=
()
=>
{
if
(
textareaRef
.
current
)
{
textareaRef
.
current
.
style
.
height
=
'auto'
textareaRef
.
current
.
style
.
height
=
`
${
textareaRef
.
current
.
scrollHeight
}
px`
}
}
const
handleChange
=
(
value
:
string
)
=>
{
setContent
(
value
)
onChange
?.(
value
)
adjustHeight
()
}
const
handleSubmit
=
()
=>
{
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChatEditor] submit click'
,
{
content
,
isAsking
,
hasOnSubmit
:
Boolean
(
onSubmit
),
})
if
(
isAsking
)
{
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChatEditor] blocked: isAsking true'
)
return
}
const
authed
=
checkAuth
()
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChatEditor] auth checked'
,
{
authed
})
if
(
!
authed
)
return
const
trimmed
=
content
.
trim
()
if
(
!
trimmed
)
{
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChatEditor] blocked: empty content'
)
return
}
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChatEditor] fire onSubmit'
,
{
trimmed
,
hasOnSubmit
:
Boolean
(
onSubmit
)
})
try
{
onSubmit
?.(
trimmed
)
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChatEditor] after onSubmit'
)
}
catch
(
error
)
{
console
.
error
(
'[TacticsChatEditor] onSubmit error'
,
error
)
}
setContent
(
''
)
if
(
textareaRef
.
current
)
{
textareaRef
.
current
.
value
=
''
adjustHeight
()
}
}
const
handleKeyDown
=
(
e
:
React
.
KeyboardEvent
<
HTMLTextAreaElement
>
)
=>
{
if
(
e
.
key
===
'Enter'
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
()
handleSubmit
()
}
}
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] bg-white border border-[#0a0a0a78] shadow-[0_8px_12px_0_rgba(235,235,235,0.30)]"
>
<
textarea
ref=
{
textareaRef
}
defaultValue=
{
initialValue
}
className=
"w-full min-h-[40px] max-h-[120px] p-2 rounded overflow-y-auto outline-none resize-none"
placeholder=
{
currentPlaceholder
}
onChange=
{
e
=>
handleChange
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
rows=
{
1
}
/>
<
Tooltip
isOpen=
{
!
content
&&
!
isAsking
}
color=
"foreground"
content=
"请输入您的问题📖"
placement=
"top-end"
>
<
Button
className=
"ask-send"
onPress=
{
handleSubmit
}
radius=
"full"
isDisabled=
{
!
content
||
isAsking
}
isIconOnly
color=
"primary"
>
<
SendIcon
/>
</
Button
>
</
Tooltip
>
</
div
>
)
}
export
const
TacticsChatEditor
=
withAuth
(
TacticsChatEditorBase
)
export
default
TacticsChatEditor
src/pages/Chat/Chat.module.less
View file @
b4dc7dee
...
...
@@ -24,8 +24,8 @@
overflow: hidden;
}
.scrollable {
flex-direction: column
-reverse
;
align-items:
center
;
flex-direction: column;
align-items:
stretch
;
display: flex;
overflow-x: hidden;
overflow-y: scroll;
...
...
src/pages/ChatTactics/TacticsChat.tsx
View file @
b4dc7dee
// 问答功能独立聊天页
import
React
,
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
'react'
import
{
useLocation
,
useNavigate
,
useParams
}
from
'react-router-dom'
import
React
,
{
useCallback
,
useEffect
,
use
Memo
,
use
Ref
,
useState
}
from
'react'
import
{
useLocation
,
useNavigate
,
useParams
,
useSearchParams
}
from
'react-router-dom'
import
{
useLocalStorageState
,
useScroll
}
from
'ahooks'
import
{
Button
}
from
'@heroui/react'
import
{
motion
}
from
'framer-motion'
...
...
@@ -9,12 +9,14 @@ import { processApiResponse } from '../Chat/helper'
import
{
ChatItemUser
}
from
'../Chat/components/ChatItem/ChatItemUser'
import
{
ChatAnswerBox
}
from
'../Chat/components/ChatItem/ChatAnswerBox'
import
{
TacticsWelcome
}
from
'./components/TacticsWelcome'
import
{
ChatEditor
}
from
'@/components/ChatEditor'
import
DeleteIcon
from
'@/assets/svg/delete.svg?react'
import
RefreshIcon
from
'@/assets/svg/refresh.svg?react'
import
{
TacticsChatEditor
}
from
'@/components/TacticsChatEditor'
import
type
{
ChatRecord
}
from
'@/types/chat'
import
{
fetchTacticsQaRecordPage
}
from
'@/api/tactics'
import
{
fetchCheckTokenApi
,
fetchStreamResponse
}
from
'@/api/chat'
import
{
fetchLoginByToken
,
fetchLoginByUid
}
from
'@/api/common'
import
{
clearTacticsNavigationFlag
,
clearTacticsShouldSendQuestion
,
createTacticsConversation
,
fetchTacticsConversations
}
from
'@/store/tacticsSlice'
import
{
clearTacticsNavigationFlag
,
clearTacticsShouldSendQuestion
,
createTacticsConversation
,
deleteTacticsConversations
,
fetchTacticsConversations
}
from
'@/store/tacticsSlice'
import
type
{
RootState
}
from
'@/store'
import
{
useAppDispatch
,
useAppSelector
}
from
'@/store/hook'
import
ScrollBtoIcon
from
'@/assets/svg/scrollBto.svg?react'
...
...
@@ -28,12 +30,15 @@ export const TacticsChat: React.FC = () => {
const
viteOutputObj
=
import
.
meta
.
env
.
VITE_OUTPUT_OBJ
||
'open'
const
[
isLoading
,
setIsLoading
]
=
useState
(
false
)
const
[
allItems
,
setAllItems
]
=
useState
<
ChatRecord
[]
>
([])
const
[
historyDividerIndex
,
setHistoryDividerIndex
]
=
useState
<
number
|
null
>
(
null
)
const
[
historyDividerTime
,
setHistoryDividerTime
]
=
useState
<
string
|
null
>
(
null
)
const
dispatch
=
useAppDispatch
()
const
{
shouldSendQuestion
:
shouldSendQuestionFromState
,
shouldNavigateToNewConversation
,
currentConversationId
,
}
=
useAppSelector
((
state
:
RootState
)
=>
state
.
tactics
)
const
[
searchParams
]
=
useSearchParams
()
const
hasFetched
=
useRef
(
false
)
const
hasCreatedRef
=
useRef
(
false
)
// 使用 useLocalStorageState 管理 token,与原有逻辑保持一致
...
...
@@ -47,6 +52,47 @@ export const TacticsChat: React.FC = () => {
const
currentIdRef
=
useRef
<
string
|
undefined
>
(
id
)
const
lastSentQuestionRef
=
useRef
<
string
>
(
''
)
const
abortControllerRef
=
useRef
<
AbortController
|
null
>
(
null
)
// 读取 from=tactics 且 version=2 的额外参数
const
tacticsMetaFromState
=
(
location
.
state
as
{
tacticsMeta
?:
any
}
|
null
)?.
tacticsMeta
const
tacticsMetaFromQuery
=
useMemo
(()
=>
{
const
from
=
searchParams
.
get
(
'from'
)
const
version
=
searchParams
.
get
(
'version'
)
if
(
from
!==
'tactics'
||
version
!==
'2'
)
{
return
undefined
}
return
{
version
,
taskId
:
searchParams
.
get
(
'taskId'
)
||
undefined
,
pinBeginTime
:
searchParams
.
get
(
'pinBeginTime'
)
||
undefined
,
pinEndTime
:
searchParams
.
get
(
'pinEndTime'
)
||
undefined
,
partOrAll
:
searchParams
.
get
(
'partOrAll'
)
||
'all'
,
channel
:
searchParams
.
get
(
'channel'
)
||
'AIphone'
,
channelName
:
searchParams
.
get
(
'channelName'
)
||
undefined
,
}
},
[
searchParams
])
const
tacticsMetaFromStorage
=
useMemo
(()
=>
{
const
raw
=
sessionStorage
.
getItem
(
'tacticsMeta'
)
if
(
!
raw
)
{
return
undefined
}
try
{
const
parsed
=
JSON
.
parse
(
raw
)
if
(
parsed
?.
version
===
'2'
)
{
return
parsed
}
}
catch
{
// ignore
}
return
undefined
},
[])
const
tacticsMeta
=
useMemo
(()
=>
tacticsMetaFromState
||
tacticsMetaFromQuery
||
tacticsMetaFromStorage
,
[
tacticsMetaFromState
,
tacticsMetaFromQuery
,
tacticsMetaFromStorage
])
const
formatCurrentTime
=
()
=>
{
const
date
=
new
Date
()
const
pad
=
(
value
:
number
)
=>
value
.
toString
().
padStart
(
2
,
'0'
)
return
`
${
date
.
getFullYear
()}
-
${
pad
(
date
.
getMonth
()
+
1
)}
-
${
pad
(
date
.
getDate
())}
${
pad
(
date
.
getHours
())}
:
${
pad
(
date
.
getMinutes
())}
:
${
pad
(
date
.
getSeconds
())}
`
}
// 创建新会话(仅在 tactics 聊天页面且没有 id 时调用)
const
initTacticsConversation
=
useCallback
(()
=>
{
...
...
@@ -124,11 +170,14 @@ export const TacticsChat: React.FC = () => {
/** 处理正常stream的数据 */
const
handleStreamMesageData
=
(
msg
:
any
,
question
:
string
)
=>
{
setAllItems
((
prevItems
)
=>
{
const
newItems
=
[...
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
]
=
{
...
...
@@ -172,7 +221,24 @@ export const TacticsChat: React.FC = () => {
}
/** 提交问题 */
const
handleSubmitQuestion
=
async
(
question
:
string
)
=>
{
const
handleSubmitQuestion
=
async
(
question
:
string
|
undefined
,
productCode
?:
string
,
toolId
?:
string
,
extra
?:
{
busiType
?:
string
,
recordType
?:
string
,
includeQuestion
?:
boolean
},
)
=>
{
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChat] handleSubmitQuestion:start'
,
{
question
,
productCode
,
toolIdFromProp
:
toolId
,
sessionToolId
:
sessionStorage
.
getItem
(
'currentToolId'
),
currentId
:
currentIdRef
.
current
,
extra
,
})
// 优先读取缓存中的 toolId,再回退到传参
const
sessionToolId
=
sessionStorage
.
getItem
(
'currentToolId'
)
??
undefined
const
resolvedToolId
=
toolId
??
sessionToolId
??
undefined
// 停止之前的请求
if
(
abortControllerRef
.
current
)
{
abortControllerRef
.
current
.
abort
()
...
...
@@ -183,14 +249,21 @@ export const TacticsChat: React.FC = () => {
// 检查token
await
fetchCheckTokenApi
()
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChat] handleSubmitQuestion:afterCheckToken'
,
{
currentId
:
currentIdRef
.
current
})
// 一次性添加用户问题和空的AI回答
// 一次性添加用户问题和空的AI回答;如果不需要问题(自动触发场景),只添加 AI 占位
const
shouldIncludeQuestion
=
extra
?.
includeQuestion
!==
false
&&
!!
question
setAllItems
(
prevItems
=>
[
...
prevItems
,
...(
shouldIncludeQuestion
?
[
{
role
:
'user'
,
question
,
}
as
ChatRecord
,
]
:
[]),
{
role
:
'ai'
,
answerList
:
[{
answer
:
''
}],
...
...
@@ -214,14 +287,33 @@ export const TacticsChat: React.FC = () => {
fetchUrl
=
proxy
+
fetchUrl
fetchStreamResponse
(
fetchUrl
,
{
question
,
const
shouldSendQuestion
=
extra
?.
includeQuestion
!==
false
const
requestBody
:
Record
<
string
,
any
>
=
{
conversationId
:
currentIdRef
.
current
,
stream
:
true
,
},
productCode
,
toolId
:
resolvedToolId
,
...(
tacticsMeta
||
{}),
...(
extra
?.
busiType
?
{
busiType
:
extra
.
busiType
}
:
{}),
...(
extra
?.
recordType
?
{
recordType
:
extra
.
recordType
}
:
{}),
}
if
(
shouldSendQuestion
)
{
requestBody
.
question
=
question
??
''
}
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChat] handleSubmitQuestion:requesting'
,
{
fetchUrl
,
body
:
requestBody
,
})
fetchStreamResponse
(
fetchUrl
,
requestBody
,
(
msg
)
=>
{
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChat] handleSubmitQuestion:onMessage'
,
msg
)
// 检查是否已被取消
if
(
abortControllerRef
.
current
?.
signal
.
aborted
)
{
return
...
...
@@ -229,6 +321,7 @@ export const TacticsChat: React.FC = () => {
// 处理错误
if
(
msg
?.
type
===
'ERROR'
)
{
// 如果是 AbortError,不显示错误
if
(
msg
.
content
?.
name
===
'AbortError'
)
{
return
}
...
...
@@ -237,17 +330,17 @@ export const TacticsChat: React.FC = () => {
// 正常的stream数据
if
(
msg
?.
type
===
'DATA'
&&
msg
?.
content
?.
code
===
'00000000'
)
{
handleStreamMesageData
(
msg
,
question
)
handleStreamMesageData
(
msg
,
question
||
''
)
}
if
(
msg
?.
type
===
'DATA'
&&
msg
?.
content
?.
code
===
'01010005'
)
{
handleChatMaxCount
(
msg
,
question
)
handleChatMaxCount
(
msg
,
question
||
''
)
return
}
if
(
msg
.
type
===
'END'
)
{
dispatch
(
setIsAsking
(
false
))
if
(
isNew
)
{
setTimeout
(()
=>
{
// 可以在这里刷新会话列表
dispatch
(
fetchTacticsConversations
())
},
2000
)
}
}
...
...
@@ -264,6 +357,9 @@ export const TacticsChat: React.FC = () => {
const
qaRecords
=
res
.
data
||
[]
// 始终添加 system 角色作为欢迎语
const
messages
=
[{
role
:
'system'
}
as
ChatRecord
,
...
processApiResponse
(
qaRecords
)]
const
hasHistory
=
qaRecords
.
length
>
0
setHistoryDividerIndex
(
hasHistory
?
messages
.
length
:
null
)
setHistoryDividerTime
(
hasHistory
?
formatCurrentTime
()
:
null
)
// 处理历史记录中的参考文档标记
const
processedMessages
=
messages
.
map
((
item
)
=>
{
if
(
item
.
role
===
'ai'
&&
item
.
answerList
?.[
0
]?.
answer
)
{
...
...
@@ -285,6 +381,8 @@ export const TacticsChat: React.FC = () => {
catch
{
// 如果获取失败,至少显示欢迎语
setAllItems
([{
role
:
'system'
}
as
ChatRecord
])
setHistoryDividerIndex
(
null
)
setHistoryDividerTime
(
null
)
}
finally
{
setIsLoading
(
false
)
...
...
@@ -296,6 +394,34 @@ export const TacticsChat: React.FC = () => {
scrollableRef
.
current
.
scrollTo
(
scrollableRef
.
current
.
scrollHeight
,
{
behavior
:
'smooth'
})
}
const
handleClearRecord
=
useCallback
(
async
()
=>
{
if
(
!
currentIdRef
.
current
)
return
try
{
await
dispatch
(
deleteTacticsConversations
([
currentIdRef
.
current
])).
unwrap
()
dispatch
(
createTacticsConversation
({
conversationData
:
{},
shouldNavigate
:
false
,
shouldSendQuestion
:
''
,
}),
)
}
catch
(
error
)
{
console
.
error
(
'清除记录失败:'
,
error
)
}
},
[
dispatch
])
const
handleReanalyze
=
useCallback
(
async
()
=>
{
// 重新触发一次提问,不携带 question
await
handleSubmitQuestion
(
undefined
,
undefined
,
undefined
,
{
includeQuestion
:
false
})
// 重新拉取会话列表,保持原有行为
await
dispatch
(
fetchTacticsConversations
())
if
(
currentIdRef
.
current
)
{
navigate
(
`/tactics/chat/
${
currentIdRef
.
current
}
`
)
}
},
[
dispatch
,
navigate
,
handleSubmitQuestion
])
useEffect
(()
=>
{
if
(
id
)
{
// 停止之前的请求
...
...
@@ -311,6 +437,8 @@ export const TacticsChat: React.FC = () => {
else
{
// 如果没有 id,显示欢迎语,等待登录成功后创建新会话
setAllItems
([{
role
:
'system'
}
as
ChatRecord
])
setHistoryDividerIndex
(
null
)
setHistoryDividerTime
(
null
)
setIsLoading
(
false
)
}
},
[
id
,
getUserQaRecordPage
,
dispatch
])
...
...
@@ -320,16 +448,43 @@ export const TacticsChat: React.FC = () => {
login
()
},
[
login
])
// 进入会话后自动触发一次提交(不带 question),busiType 01 / recordType A02
const
hasAutoSubmittedRef
=
useRef
(
false
)
useEffect
(()
=>
{
if
(
currentIdRef
.
current
&&
!
isLoading
&&
!
hasAutoSubmittedRef
.
current
)
{
hasAutoSubmittedRef
.
current
=
true
handleSubmitQuestion
(
undefined
,
undefined
,
undefined
,
{
busiType
:
'01'
,
recordType
:
'A02'
,
includeQuestion
:
false
},
)
}
},
[
isLoading
,
handleSubmitQuestion
])
// 创建新会话成功后跳转到新会话页面
useEffect
(()
=>
{
if
(
shouldNavigateToNewConversation
&&
currentConversationId
)
{
navigate
(
`/tactics/chat/
${
currentConversationId
}
`
)
navigate
(
`/tactics/chat/
${
currentConversationId
}
`
,
{
state
:
{
shouldSendQuestion
:
shouldSendQuestionFromState
,
tacticsMeta
,
},
replace
:
true
,
})
dispatch
(
clearTacticsNavigationFlag
())
}
},
[
shouldNavigateToNewConversation
,
currentConversationId
,
navigate
,
dispatch
])
},
[
shouldNavigateToNewConversation
,
currentConversationId
,
navigate
,
dispatch
,
shouldSendQuestionFromState
,
tacticsMeta
])
// 处理shouldSendQuestion的变化 - 自动发送问题
useEffect
(()
=>
{
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChat] autoSend check'
,
{
shouldSendQuestion
,
currentId
:
currentIdRef
.
current
,
isLoading
,
lastSentQuestion
:
lastSentQuestionRef
.
current
,
})
if
(
shouldSendQuestion
&&
currentIdRef
.
current
...
...
@@ -351,16 +506,31 @@ export const TacticsChat: React.FC = () => {
height
:
'100vh'
,
display
:
'flex'
,
flexDirection
:
'column'
,
width
:
'420px'
,
marginLeft
:
'auto'
,
marginRight
:
0
,
position
:
'fixed'
,
right
:
0
,
top
:
0
,
backgroundColor
:
'#FFFFFF'
,
zIndex
:
1000
,
}
}
>
{
/* 右上操作区 */
}
<
div
className=
"flex justify-end items-center gap-[16px] px-[16px] pt-[16px] pb-[8px]"
>
<
button
type=
"button"
onClick=
{
handleClearRecord
}
className=
"flex items-center gap-[4px] text-[#4A90E2] text-[14px] hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none outline-none"
>
<
DeleteIcon
className=
"w-[16px] h-[16px] text-[#B2B8C1]"
/>
<
span
>
清除记录
</
span
>
</
button
>
<
button
type=
"button"
onClick=
{
handleReanalyze
}
className=
"flex items-center gap-[4px] text-[#4A90E2] text-[14px] hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none outline-none"
>
<
RefreshIcon
className=
"w-[16px] h-[16px] text-[#B2B8C1]"
/>
<
span
>
重新分析
</
span
>
</
button
>
</
div
>
<
div
className=
{
`${styles.chatPage} relative flex flex-col h-full`
}
>
<
div
className=
{
`${styles.content} flex-1 overflow-hidden flex flex-col`
}
>
{
isLoading
&&
(
...
...
@@ -379,16 +549,23 @@ export const TacticsChat: React.FC = () => {
}
}
className=
{
`${styles.scrollable} scrollbar-hide scroll-smooth flex-1 overflow-y-auto px-[16px]`
}
>
<
div
className=
{
`${styles.inter} p
y
-[24px]`
}
>
<
div
className=
{
`${styles.inter} p
t-[8px] pb
-[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
(
<
React
.
Fragment
key=
{
uniqueKey
}
>
{
historyDividerIndex
!==
null
&&
index
===
historyDividerIndex
&&
(
<
div
className=
"flex items-center gap-[12px] my-[16px] text-[#B2B8C1] text-[12px]"
>
<
div
className=
"flex-1 h-px bg-[#E4E7EC]"
/>
<
span
>
{
historyDividerTime
||
formatCurrentTime
()
}
</
span
>
<
div
className=
"flex-1 h-px bg-[#E4E7EC]"
/>
</
div
>
)
}
<
div
className=
"w-full chatItem mx-auto"
key=
{
uniqueKey
}
>
{
record
.
role
===
'system'
&&
<
TacticsWelcome
/>
}
{
record
.
role
===
'user'
&&
<
ChatItemUser
record=
{
record
}
/>
}
...
...
@@ -402,6 +579,7 @@ export const TacticsChat: React.FC = () => {
/>
)
}
</
div
>
</
React
.
Fragment
>
)
})
}
</
div
>
...
...
@@ -425,10 +603,22 @@ export const TacticsChat: React.FC = () => {
</
Button
>
</
motion
.
div
>
</
div
>
<
ChatEditor
onSubmit=
{
handleSubmitQuestion
}
placeholders=
{
[]
}
hideTools
<
TacticsChatEditor
onSubmit=
{
(
question
)
=>
{
// eslint-disable-next-line no-console
console
.
log
(
'[TacticsChat] onSubmit wrapper'
,
{
question
,
currentId
:
currentIdRef
.
current
,
isLoading
,
})
return
handleSubmitQuestion
(
question
,
undefined
,
undefined
,
{
busiType
:
'02'
,
recordType
:
'A01'
,
includeQuestion
:
true
},
)
}
}
placeholders=
{
[
''
]
}
/>
<
div
className=
"w-full text-center mt-[12px] text-[#3333334d] text-[12px]"
>
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
...
...
src/pages/ChatTactics/TacticsHome.tsx
View file @
b4dc7dee
// 问答功能独立首页
import
type
React
from
'react'
import
{
useCallback
,
useEffect
,
useRef
}
from
'react'
import
{
useLocation
,
useNavigate
}
from
'react-router-dom'
import
{
useCallback
,
useEffect
,
use
Memo
,
use
Ref
}
from
'react'
import
{
useLocation
,
useNavigate
,
useSearchParams
}
from
'react-router-dom'
import
{
useLocalStorageState
}
from
'ahooks'
import
styles
from
'../Home/Home.module.less'
import
{
TacticsWelcome
}
from
'./components/TacticsWelcome'
import
{
clearTacticsNavigationFlag
,
createTacticsConversation
,
deleteTacticsConversations
,
fetchTacticsConversations
}
from
'@/store/tacticsSlice'
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'
import
{
fetchCheckTokenApi
,
fetchStreamResponse
}
from
'@/api/chat'
import
DeleteIcon
from
'@/assets/svg/delete.svg?react'
import
RefreshIcon
from
'@/assets/svg/refresh.svg?react'
import
{
TacticsChatEditor
}
from
'@/components/TacticsChatEditor'
export
const
TacticsHome
:
React
.
FC
=
()
=>
{
const
viteOutputObj
=
import
.
meta
.
env
.
VITE_OUTPUT_OBJ
||
'open'
const
dispatch
=
useAppDispatch
()
const
location
=
useLocation
()
const
navigate
=
useNavigate
()
const
[
searchParams
]
=
useSearchParams
()
const
hasFetched
=
useRef
(
false
)
const
{
shouldNavigateToNewConversation
,
currentConversationId
,
shouldSendQuestion
}
=
useAppSelector
((
state
:
RootState
)
=>
state
.
tactics
)
const
[
token
,
setToken
]
=
useLocalStorageState
<
string
|
undefined
>
(
'__TOKEN__'
,
{
defaultValue
:
''
,
})
const
abortControllerRef
=
useRef
<
AbortController
|
null
>
(
null
)
// 仅在 from=tactics 且 version=2 时读取并缓存额外参数
const
tacticsMetaFromSearch
=
useMemo
(()
=>
{
const
from
=
searchParams
.
get
(
'from'
)
const
version
=
searchParams
.
get
(
'version'
)
if
(
from
!==
'tactics'
||
version
!==
'2'
)
{
return
undefined
}
return
{
version
,
taskId
:
searchParams
.
get
(
'taskId'
)
||
undefined
,
pinBeginTime
:
searchParams
.
get
(
'pinBeginTime'
)
||
undefined
,
pinEndTime
:
searchParams
.
get
(
'pinEndTime'
)
||
undefined
,
partOrAll
:
searchParams
.
get
(
'partOrAll'
)
||
'all'
,
channel
:
searchParams
.
get
(
'channel'
)
||
'AIphone'
,
channelName
:
searchParams
.
get
(
'channelName'
)
||
undefined
,
}
},
[
searchParams
])
const
tacticsMetaFromStorage
=
useMemo
(()
=>
{
const
raw
=
sessionStorage
.
getItem
(
'tacticsMeta'
)
if
(
!
raw
)
{
return
undefined
}
try
{
const
parsed
=
JSON
.
parse
(
raw
)
if
(
parsed
?.
from
===
'tactics'
&&
parsed
?.
version
===
'2'
)
{
return
parsed
}
}
catch
{
// ignore
}
return
undefined
},
[])
const
tacticsMeta
=
useMemo
(()
=>
tacticsMetaFromSearch
||
tacticsMetaFromStorage
,
[
tacticsMetaFromSearch
,
tacticsMetaFromStorage
])
// 同步到 sessionStorage,便于跳转后读取;仅在有 meta 时写入,避免覆盖重定向缓存
useEffect
(()
=>
{
if
(
tacticsMeta
)
{
sessionStorage
.
setItem
(
'tacticsMeta'
,
JSON
.
stringify
(
tacticsMeta
))
}
},
[
tacticsMeta
])
const
initTacticsConversation
=
()
=>
{
const
fromCollect
=
location
.
state
?.
fromCollect
...
...
@@ -51,47 +89,12 @@ export const TacticsHome: React.FC = () => {
const
handleCreateConversation
=
useCallback
(
async
(
question
:
string
)
=>
{
// 如果已有会话,直接调用 submit 接口提交问题,然后跳转到聊天页面
if
(
currentConversationId
)
{
// 停止之前的请求
if
(
abortControllerRef
.
current
)
{
abortControllerRef
.
current
.
abort
()
}
// 检查token
await
fetchCheckTokenApi
()
// 创建新的 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
// 直接调用 submit 接口(消息流会在聊天页面处理,这里只负责发起请求)
fetchStreamResponse
(
fetchUrl
,
{
question
,
conversationId
:
currentConversationId
,
stream
:
true
,
},
()
=>
{
// 在首页不需要处理消息,跳转到聊天页面后会自动刷新加载最新消息
navigate
(
`/tactics/chat/
${
currentConversationId
}
`
,
{
state
:
{
shouldSendQuestion
:
question
,
tacticsMeta
,
},
abortControllerRef
.
current
.
signal
,
)
// 跳转到聊天页面查看结果
navigate
(
`/tactics/chat/
${
currentConversationId
}
`
)
})
return
}
// 如果没有会话,才创建新会话
...
...
@@ -102,7 +105,7 @@ export const TacticsHome: React.FC = () => {
shouldSendQuestion
:
question
,
}),
)
},
[
dispatch
,
currentConversationId
,
navigate
])
},
[
dispatch
,
currentConversationId
,
navigate
,
tacticsMeta
])
// 监听导航到新对话
useEffect
(()
=>
{
...
...
@@ -111,11 +114,13 @@ export const TacticsHome: React.FC = () => {
navigate
(
url
,
{
state
:
{
shouldSendQuestion
,
tacticsMeta
,
},
replace
:
true
,
})
dispatch
(
clearTacticsNavigationFlag
())
}
},
[
shouldNavigateToNewConversation
,
currentConversationId
,
shouldSendQuestion
,
navigate
,
dispatch
])
},
[
shouldNavigateToNewConversation
,
currentConversationId
,
shouldSendQuestion
,
navigate
,
dispatch
,
tacticsMeta
])
const
login
=
useCallback
(
async
()
=>
{
if
(
hasFetched
.
current
)
{
...
...
@@ -178,35 +183,6 @@ export const TacticsHome: React.FC = () => {
},
[])
// 处理清除记录
const
handleClearRecord
=
useCallback
(
async
()
=>
{
if
(
!
currentConversationId
)
{
return
}
try
{
await
dispatch
(
deleteTacticsConversations
([
currentConversationId
])).
unwrap
()
// 清除后创建新会话
dispatch
(
createTacticsConversation
({
conversationData
:
{},
shouldNavigate
:
false
,
shouldSendQuestion
:
''
,
}),
)
}
catch
(
error
)
{
console
.
error
(
'清除记录失败:'
,
error
)
}
},
[
currentConversationId
,
dispatch
])
// 处理重新分析
const
handleReanalyze
=
useCallback
(
async
()
=>
{
// 重新获取会话列表
await
dispatch
(
fetchTacticsConversations
())
// 如果有当前会话,可以重新加载
if
(
currentConversationId
)
{
navigate
(
`/tactics/chat/
${
currentConversationId
}
`
)
}
},
[
currentConversationId
,
dispatch
,
navigate
])
return
(
<
div
...
...
@@ -215,36 +191,12 @@ export const TacticsHome: React.FC = () => {
height
:
'100vh'
,
display
:
'flex'
,
flexDirection
:
'column'
,
width
:
'420px'
,
marginLeft
:
'auto'
,
marginRight
:
0
,
position
:
'fixed'
,
right
:
0
,
top
:
0
,
backgroundColor
:
'#FFFFFF'
,
zIndex
:
1000
,
}
}
>
{
/* 按钮区域 - 右侧展示 */
}
<
div
className=
"flex justify-end items-center gap-[16px] px-[16px] pt-[16px] pb-[8px]"
>
<
button
type=
"button"
onClick=
{
handleClearRecord
}
className=
"flex items-center gap-[4px] text-[#4A90E2] text-[14px] hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none outline-none"
>
<
DeleteIcon
className=
"w-[16px] h-[16px] text-[#B2B8C1]"
/>
<
span
>
清除记录
</
span
>
</
button
>
<
button
type=
"button"
onClick=
{
handleReanalyze
}
className=
"flex items-center gap-[4px] text-[#4A90E2] text-[14px] hover:opacity-80 transition-opacity cursor-pointer bg-transparent border-none outline-none"
>
<
RefreshIcon
className=
"w-[16px] h-[16px] text-[#B2B8C1]"
/>
<
span
>
重新分析
</
span
>
</
button
>
</
div
>
<
div
className=
"h-full w-full flex flex-col"
>
{
/* 主要内容区域 - 全屏显示 */
}
<
div
className=
"flex-1 overflow-hidden flex flex-col"
>
...
...
@@ -254,11 +206,9 @@ export const TacticsHome: React.FC = () => {
</
div
>
{
/* 底部输入框 */
}
<
div
className=
"box-border px-[16px] pb-[18px] pt-[12px] bg-white border-t border-gray-100"
>
<
ChatEditor
showContentTips
<
TacticsChatEditor
onSubmit=
{
handleCreateConversation
}
placeholders=
{
RECOMMEND_QUESTIONS_OTHER
}
hideTools
placeholders=
{
[
''
]
}
/>
<
div
className=
"w-full text-center mt-[12px] text-[#3333334d] text-[12px]"
>
内容由AI模型生成,其准确性和完整性无法保证,仅供参考
...
...
src/pages/ChatTactics/components/TacticsWelcome/index.tsx
View file @
b4dc7dee
...
...
@@ -10,7 +10,7 @@ export const TacticsWelcome: React.FC = () => {
return
(
<
div
className=
"chatWelcomeContainer w-full"
>
<
div
className=
"h-[
20px] sm:h-[32
px] w-full"
></
div
>
<
div
className=
"h-[
8px] sm:h-[16
px] w-full"
></
div
>
<
div
className=
"flex items-start"
>
<
Avatar
className=
"mr-[12px] flex-shrink-0"
src=
{
viteOutputObj
===
'inner'
?
AIIcon
:
AvatarBot
}
/>
<
motion
.
div
...
...
@@ -27,7 +27,7 @@ export const TacticsWelcome: React.FC = () => {
</
div
>
</
motion
.
div
>
</
div
>
<
div
className=
"h-[
20px] sm:h-[32
px] w-full"
></
div
>
<
div
className=
"h-[
12px] sm:h-[24
px] w-full"
></
div
>
</
div
>
)
}
src/routes/AppRoutes.tsx
View file @
b4dc7dee
...
...
@@ -15,9 +15,8 @@ const AppRoutesComponent: React.FC = () => {
<
Route
path=
"/chat/:id"
element=
{
<
Chat
/>
}
/>
</
Route
>
<
Route
path=
"/home"
element=
{
<
Home
/>
}
></
Route
>
<
Route
path=
"/tactics"
element=
{
<
TacticsHome
/>
}
>
<
Route
path=
"/tactics"
element=
{
<
TacticsHome
/>
}
/
>
<
Route
path=
"/tactics/chat/:id"
element=
{
<
TacticsChat
/>
}
/>
</
Route
>
<
Route
path=
"/collect"
element=
{
<
Collect
/>
}
/>
<
Route
path=
"/tools"
element=
{
<
Tools
/>
}
/>
<
Route
path=
"/protocol/:id"
element=
{
<
Protocol
/>
}
/>
...
...
src/routes/RouteChangeHandler.tsx
View file @
b4dc7dee
...
...
@@ -23,6 +23,23 @@ export function withRouteChangeHandler(WrappedComponent: React.ComponentType) {
const
searchParams
=
new
URLSearchParams
(
location
.
search
)
const
from
=
searchParams
.
get
(
'from'
)
if
(
from
===
'tactics'
)
{
// 在重定向前缓存查询参数,供 /tactics 页读取(避免 from 被清理导致丢失)
try
{
const
meta
=
{
version
:
searchParams
.
get
(
'version'
)
||
undefined
,
taskId
:
searchParams
.
get
(
'taskId'
)
||
undefined
,
pinBeginTime
:
searchParams
.
get
(
'pinBeginTime'
)
||
undefined
,
pinEndTime
:
searchParams
.
get
(
'pinEndTime'
)
||
undefined
,
partOrAll
:
searchParams
.
get
(
'partOrAll'
)
||
'all'
,
channel
:
searchParams
.
get
(
'channel'
)
||
'AIphone'
,
channelName
:
searchParams
.
get
(
'channelName'
)
||
undefined
,
from
,
}
sessionStorage
.
setItem
(
'tacticsMeta'
,
JSON
.
stringify
(
meta
))
}
catch
{
// ignore
}
// 重定向到 /tactics,保留其他查询参数
const
newSearchParams
=
new
URLSearchParams
(
location
.
search
)
newSearchParams
.
delete
(
'from'
)
...
...
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