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
c877b1a4
Commit
c877b1a4
authored
May 28, 2026
by
Liu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix:底部按钮基本逻辑
parent
e879c818
Hide whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
1588 additions
and
530 deletions
+1588
-530
src/api/common.ts
+9
-0
src/components/ChatEditor/index.tsx
+43
-73
src/layouts/Navbar/Navbar.tsx
+9
-2
src/lib/toolRoleRecordTypes.ts
+19
-1
src/lib/workflowToolParams.ts
+9
-10
src/pages/Chat/Chat.tsx
+631
-240
src/pages/Chat/components/ChatItem/CardWorkflowField.tsx
+60
-24
src/pages/Chat/components/ChatItem/CardWorkflowFileField.tsx
+64
-0
src/pages/Chat/components/ChatItem/CardWorkflowForm.tsx
+131
-159
src/pages/Chat/components/ChatItem/ChatAnswerAttchment.tsx
+3
-2
src/pages/Chat/components/ChatItem/ChatAnswerBox.tsx
+3
-2
src/pages/Chat/components/ChatItem/ChatAnswerParser.tsx
+14
-6
src/pages/Chat/components/ChatItem/ChatAnswerShower.tsx
+15
-5
src/pages/Chat/components/ChatItem/ChatItemUser.tsx
+5
-1
src/pages/Chat/helper.ts
+313
-3
src/pages/Chat/lib/workflowCardForm.ts
+256
-0
src/store/conversationSlice.ts
+1
-1
src/types/chat.ts
+2
-0
src/types/conversation.ts
+1
-1
No files found.
src/api/common.ts
View file @
c877b1a4
...
...
@@ -60,3 +60,12 @@ export function fetchDownloadFile(docId: string) {
responseType
:
'blob'
,
})
}
/**
* 工作流表单文件上传(知识库上传,返回文件访问 URL,写入 paramMap[field.key] 数组)
*/
export
function
fetchUploadWorkflowFile
(
file
:
File
)
{
const
formData
=
new
FormData
()
formData
.
append
(
'file'
,
file
)
return
http
.
post
<
{
data
?:
unknown
}
>
(
'/api/v1/knowledge/upload'
,
formData
)
}
src/components/ChatEditor/index.tsx
View file @
c877b1a4
...
...
@@ -12,10 +12,11 @@ import { clearBusinessInsightWorkflow, clearCurrentToolId, setCurrentToolId } fr
import
{
setIsAsking
}
from
'@/store/chatSlice'
import
{
fetchToolList
}
from
'@/api/home'
import
{
safeSessionStorageGetItem
,
safeSessionStorageRemoveItem
,
safeSessionStorageSetItem
}
from
'@/lib/utils'
import
{
clearWorkflowToolCaches
,
getWorkflowToolIdAndRecordTypeForRequest
,
setWorkflowRequestSeedPair
}
from
'@/lib/workflowToolParams'
import
{
clearWorkflowToolCaches
}
from
'@/lib/workflowToolParams'
import
type
{
ToolListRow
}
from
'@/types/toolList'
import
{
getToolQuestionPairFromRow
,
isGeneralInstitutionalToolRow
,
normalizeToolId
,
readToolListFromSessionStorage
}
from
'@/lib/toolRoleRecordTypes'
import
{
getToolQuestionPairFromRow
,
isGeneralInstitutionalToolRow
,
normalizeToolId
}
from
'@/lib/toolRoleRecordTypes'
import
{
fetchSessionConversationId
,
fetchUserQaRecordPage
}
from
'@/api/conversation'
import
{
hasQaWorkflowContext
}
from
'@/pages/Chat/helper'
interface
ChatEditorProps
{
onChange
?:
(
value
:
string
)
=>
void
...
...
@@ -26,9 +27,11 @@ interface ChatEditorProps {
showContentTips
?:
boolean
initialValue
?:
string
hideTools
?:
boolean
// 隐藏工具选择部分
/** 工作流表单展示且未提交时禁用底部输入 */
inputDisabled
?:
boolean
}
// onToolClick
const
ChatEditorBase
:
React
.
FC
<
ChatEditorProps
&
WithAuthProps
>
=
({
checkAuth
,
onChange
,
onFocus
,
onSubmit
,
onToolClick
,
placeholders
,
showContentTips
=
false
,
initialValue
=
''
,
hideTools
=
false
})
=>
{
const
ChatEditorBase
:
React
.
FC
<
ChatEditorProps
&
WithAuthProps
>
=
({
checkAuth
,
onChange
,
onFocus
,
onSubmit
,
onToolClick
,
placeholders
,
showContentTips
=
false
,
initialValue
=
''
,
hideTools
=
false
,
inputDisabled
=
false
})
=>
{
// const dispatch = useAppDispatch()
const
[
content
,
setContent
]
=
useState
(
initialValue
)
const
dispatch
=
useAppDispatch
()
...
...
@@ -39,7 +42,6 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
const
isAsking
=
useAppSelector
((
state
:
RootState
)
=>
state
.
chat
.
isAsking
)
const
[
toolList
,
setToolList
]
=
useState
<
ToolListRow
[]
>
([])
const
[
selectedToolId
,
setSelectedToolId
]
=
useState
<
string
|
null
>
(
null
)
const
[
isToolBtnActive
,
setIsToolBtnActive
]
=
useState
<
boolean
>
(
true
)
const
currentToolId
=
useAppSelector
((
state
:
RootState
)
=>
state
.
conversation
.
currentToolId
)
const
[
showToolQuestion
,
setShowToolQuestion
]
=
useState
<
boolean
>
(
false
)
const
[
sessionToolId
,
setSessionToolId
]
=
useState
<
string
|
null
>
(
null
)
...
...
@@ -176,8 +178,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
newSearchParams
.
delete
(
'toolId'
)
setSearchParams
(
newSearchParams
,
{
replace
:
true
})
}
setSelectedToolId
(
null
)
setIsToolBtnActive
(
true
)
setSelectedToolId
(
toolList
.
length
>
0
?
normalizeToolId
(
toolList
[
0
].
toolId
)
:
null
)
return
}
...
...
@@ -185,8 +186,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
const
newSearchParams
=
new
URLSearchParams
(
searchParams
)
newSearchParams
.
delete
(
'toolId'
)
setSearchParams
(
newSearchParams
,
{
replace
:
true
})
setSelectedToolId
(
null
)
setIsToolBtnActive
(
true
)
setSelectedToolId
(
toolList
.
length
>
0
?
normalizeToolId
(
toolList
[
0
].
toolId
)
:
null
)
return
}
...
...
@@ -195,48 +195,38 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
if
(
currentToolId
&&
hasPersistentToolSource
)
{
setSelectedToolId
(
currentToolId
)
setIsToolBtnActive
(
false
)
return
}
if
(
sessionToolId
)
{
setSelectedToolId
(
sessionToolId
)
setIsToolBtnActive
(
false
)
return
}
// 如果 currentToolId 和 sessionToolId 都没有值,根据路由中的 toolId 来决定
if
(
toolIdFromUrl
)
{
setSelectedToolId
(
toolIdFromUrl
)
setIsToolBtnActive
(
false
)
}
else
{
setSelectedToolId
(
null
)
setIsToolBtnActive
(
true
)
setSelectedToolId
(
toolList
.
length
>
0
?
normalizeToolId
(
toolList
[
0
].
toolId
)
:
null
)
}
},
[
currentToolId
,
sessionToolId
,
toolIdFromUrl
,
searchParams
,
setSearchParams
,
fromCollect
])
},
[
currentToolId
,
sessionToolId
,
toolIdFromUrl
,
searchParams
,
setSearchParams
,
fromCollect
,
toolList
])
// 监听 sessionStorage 中的 currentToolId(历史点击时写入)来辅助高亮逻辑
useEffect
(()
=>
{
const
syncSessionToolId
=
()
=>
{
const
storedToolId
=
safeSessionStorageGetItem
(
'currentToolId'
)
// 如果 currentToolId 是空字符串,视为 null,确保制度活化能正确高亮
setSessionToolId
(
storedToolId
&&
storedToolId
.
trim
()
?
storedToolId
:
null
)
}
syncSessionToolId
()
window
.
addEventListener
(
'storage'
,
syncSessionToolId
)
// 监听强制重置为制度活化的事件(登录成功后触发)
const
handleForceReset
=
()
=>
{
// 强制同步 sessionStorage(此时应该已经被清除了)
syncSessionToolId
()
// 确保 Redux 也被清除(如果还没有)
if
(
currentToolId
)
{
dispatch
(
clearCurrentToolId
())
}
// 强制设置为制度活化
setSelectedToolId
(
null
)
setIsToolBtnActive
(
true
)
setSelectedToolId
(
toolList
.
length
>
0
?
normalizeToolId
(
toolList
[
0
].
toolId
)
:
null
)
}
window
.
addEventListener
(
'forceResetToGeneralMode'
,
handleForceReset
)
...
...
@@ -244,12 +234,11 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
window
.
removeEventListener
(
'storage'
,
syncSessionToolId
)
window
.
removeEventListener
(
'forceResetToGeneralMode'
,
handleForceReset
)
}
},
[
currentToolId
,
dispatch
])
},
[
currentToolId
,
dispatch
,
toolList
])
// 当路由变化时,同步更新 sessionToolId(因为 storage 事件不会在同标签页触发)
useEffect
(()
=>
{
const
storedToolId
=
safeSessionStorageGetItem
(
'currentToolId'
)
// 如果 currentToolId 是空字符串,视为 null,确保制度活化能正确高亮
setSessionToolId
(
storedToolId
&&
storedToolId
.
trim
()
?
storedToolId
:
null
)
// 当路由切换(如点击历史记录)时,同步最新的 sessionStorage,避免同标签页删除后状态不同步
},
[
toolIdFromUrl
,
location
.
pathname
])
...
...
@@ -277,25 +266,19 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
}
}
const
editorLocked
=
isAsking
||
inputDisabled
const
handleSubmit
=
()
=>
{
if
(
isAsking
)
if
(
editorLocked
)
return
// 以 DOM 为准,避免 content 状态与 contentEditable 不同步(如 IME、快速连点)导致误判为空
const
textFromDom
=
(
editorRef
.
current
?.
textContent
??
''
).
trim
()
const
text
=
textFromDom
||
content
.
trim
()
if
(
checkAuth
())
{
if
(
text
)
{
// 只在提质增效模式下传递 toolId,制度活化不传
let
toolId
:
string
|
undefined
if
(
selectedToolId
)
{
// 提质增效模式:使用选中的 toolId
toolId
=
selectedToolId
}
else
{
// 制度活化:不传递 toolId
toolId
=
undefined
}
const
defaultToolId
=
toolList
.
length
>
0
?
normalizeToolId
(
toolList
[
0
].
toolId
)
:
null
const
activeToolId
=
selectedToolId
??
sessionToolId
??
toolIdFromUrl
??
defaultToolId
const
toolId
=
activeToolId
||
undefined
onSubmit
?.(
text
,
toolId
)
setContent
(
''
)
if
(
editorRef
.
current
)
{
...
...
@@ -360,8 +343,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
if
(
isGeneral
)
{
dispatch
(
clearCurrentToolId
())
setIsToolBtnActive
(
true
)
setSelectedToolId
(
null
)
setSelectedToolId
(
normalizeToolId
(
tool
.
toolId
))
safeSessionStorageRemoveItem
(
'showToolQuestion'
)
safeSessionStorageRemoveItem
(
'currentToolId'
)
setSessionToolId
(
null
)
...
...
@@ -384,7 +366,6 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
}
dispatch
(
setCurrentToolId
(
normalizedToolId
))
setSelectedToolId
(
normalizedToolId
)
setIsToolBtnActive
(
false
)
safeSessionStorageSetItem
(
'currentToolId'
,
normalizedToolId
)
setSessionToolId
(
normalizedToolId
)
}
...
...
@@ -436,30 +417,21 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
},
}))
if
(
!
isGeneral
)
{
const
listForPair
=
toolList
?.
length
?
toolList
:
readToolListFromSessionStorage
()
const
clickedPair
=
getToolQuestionPairFromRow
(
tool
)
if
(
clickedPair
.
toolId
&&
clickedPair
.
recordType
)
setWorkflowRequestSeedPair
(
clickedPair
.
toolId
,
clickedPair
.
recordType
)
const
{
toolId
:
wfToolId
,
recordType
:
wfRecordType
}
=
getWorkflowToolIdAndRecordTypeForRequest
(
listForPair
)
if
(
wfToolId
&&
wfRecordType
)
{
console
.
log
(
'[ChatEditor] 工具会话 - 准备延迟触发 autoSubmitQuestion'
,
{
conversationId
,
toolId
:
wfToolId
,
recordType
:
wfRecordType
})
requestAnimationFrame
(()
=>
{
requestAnimationFrame
(()
=>
{
console
.
log
(
'[ChatEditor] 工具会话 - 触发 autoSubmitQuestion 事件'
,
{
conversationId
,
toolId
:
wfToolId
,
recordType
:
wfRecordType
})
window
.
dispatchEvent
(
new
CustomEvent
(
'autoSubmitQuestion'
,
{
detail
:
{
conversationId
,
question
:
''
,
toolId
:
wfToolId
,
recordType
:
wfRecordType
,
busiType
:
'01'
,
},
}))
})
})
const
qaList
=
Array
.
isArray
(
qaRes
?.
data
)
?
qaRes
.
data
:
[]
if
(
!
hasQaWorkflowContext
(
qaList
))
{
const
pair
=
getToolQuestionPairFromRow
(
tool
)
if
(
pair
.
recordType
)
{
try
{
sessionStorage
.
setItem
(
'pendingWorkflowSilentBootstrap'
,
JSON
.
stringify
({
conversationId
,
toolId
:
isGeneral
?
''
:
normalizedToolId
,
recordType
:
pair
.
recordType
,
busiType
:
'01'
,
}))
}
catch
{
// ignore
}
}
}
sessionStorage
.
removeItem
(
'toolHistoryLoading'
)
...
...
@@ -551,15 +523,15 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
>
<
div
ref=
{
editorRef
}
contentEditable
enterKeyHint=
"send"
role=
"textbox"
translate=
"no"
className=
"w-full min-h-[40px] word-break-break-all max-h-[48px] p-2 rounded overflow-y-auto outline-none"
onInput=
{
handleInput
}
onFocus=
{
onFocus
}
onKeyDown=
{
handleKeyDown
}
onPaste=
{
handlePaste
}
onKeyDown=
{
editorLocked
?
undefined
:
handleKeyDown
}
onPaste=
{
editorLocked
?
undefined
:
handlePaste
}
contentEditable=
{
!
editorLocked
}
suppressContentEditableWarning
data
-
placeholder=
{
showToolQuestion
?
'输入您想问的问题吧~'
:
'输入您想问的问题吧~'
}
style=
{
{
...
...
@@ -567,11 +539,12 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
maxHeight
:
'48px'
,
wordBreak
:
'break-all'
,
...(
!
hideTools
&&
toolList
&&
toolList
.
length
>
0
?
{
marginBottom
:
'40px'
}
:
{}),
...(
editorLocked
?
{
pointerEvents
:
'none'
as
const
,
opacity
:
0.5
}
:
{}),
}
}
>
</
div
>
<
Tooltip
isOpen=
{
Boolean
(
token
)
&&
showContentTips
&&
!
isAsking
&&
!
content
}
color=
"foreground"
content=
"请输入您的问题📖"
placement=
"top-end"
>
<
Button
className=
"ask-send"
onPress=
{
handleSubmit
}
radius=
"full"
isDisabled=
{
!
content
||
isAsking
}
isIconOnly
color=
"primary"
>
<
Tooltip
isOpen=
{
Boolean
(
token
)
&&
showContentTips
&&
!
editorLocked
&&
!
content
}
color=
"foreground"
content=
"请输入您的问题📖"
placement=
"top-end"
>
<
Button
className=
"ask-send"
onPress=
{
handleSubmit
}
radius=
"full"
isDisabled=
{
!
content
||
editorLocked
}
isIconOnly
color=
"primary"
>
<
SendIcon
/>
</
Button
>
</
Tooltip
>
...
...
@@ -605,13 +578,10 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
{
!
hideTools
&&
toolList
&&
toolList
.
length
>
0
&&
(
<
div
className=
"absolute left-4 bottom-2 flex items-center gap-3 pointer-events-auto pl-[16px]"
>
{
toolList
.
map
((
tool
:
ToolListRow
,
index
:
number
)
=>
{
// 根据 selectedToolId 或路由中的 toolId 进行匹配,注意数据类型统一转换为字符串比较
// 优先使用 selectedToolId(用户点击后的状态),其次使用 sessionStorage 中的 currentToolId(刷新时使用),再次使用路由中的 toolId(初始化时使用)
const
toolIdStr
=
normalizeToolId
(
tool
.
toolId
)
const
isSelectedByState
=
Boolean
(
selectedToolId
)
&&
toolIdStr
===
String
(
selectedToolId
)
const
isSelectedBySession
=
!
selectedToolId
&&
sessionToolId
&&
toolIdStr
===
String
(
sessionToolId
)
const
isGeneralMode
=
isGeneralInstitutionalToolRow
(
tool
)
&&
isToolBtnActive
&&
!
selectedToolId
&&
!
sessionToolId
&&
!
toolIdFromUrl
const
isSelected
=
isSelectedByState
||
isSelectedBySession
||
isGeneralMode
const
defaultToolId
=
toolList
.
length
>
0
?
normalizeToolId
(
toolList
[
0
].
toolId
)
:
null
const
activeToolId
=
selectedToolId
??
sessionToolId
??
toolIdFromUrl
??
defaultToolId
const
isSelected
=
activeToolId
!=
null
&&
toolIdStr
===
String
(
activeToolId
)
const
baseBtnClass
=
'w-auto h-[32px] px-3 rounded-full shadow-none text-[12px] flex items-center gap-2 transition-all duration-200 border'
...
...
src/layouts/Navbar/Navbar.tsx
View file @
c877b1a4
...
...
@@ -5,7 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import
{
useClickAway
,
useSessionStorageState
}
from
'ahooks'
import
styles
from
'./Navbar.module.less'
import
{
NavBarItem
}
from
'./components/NavBarItem'
import
{
clearNavigationFlag
,
createConversation
}
from
'@/store/conversationSlice'
import
{
clear
BusinessInsightWorkflow
,
clear
NavigationFlag
,
createConversation
}
from
'@/store/conversationSlice'
import
{
createTacticsConversation
}
from
'@/store/tacticsSlice'
import
type
{
WithAuthProps
}
from
'@/auth/withAuth'
import
{
withAuth
}
from
'@/auth/withAuth'
...
...
@@ -15,6 +15,7 @@ import { isMobile } from '@/utils'
import
{
safeLocalStorageSetItem
,
waitForToken
}
from
'@/lib/utils'
import
{
fetchUserQaRecordPage
}
from
'@/api/conversation'
import
{
findToolRowBySessionToolId
,
getToolQuestionPairFromRow
,
readToolListFromSessionStorage
,
toolRowTriggersEmptyQuestionBootstrap
}
from
'@/lib/toolRoleRecordTypes'
import
{
hasQaWorkflowContext
}
from
'@/pages/Chat/helper'
// import { User } from './components/User'
interface
NavbarProps
{
...
...
@@ -32,6 +33,7 @@ const NavbarBase: React.FC<NavbarProps & WithAuthProps> = ({ isHistoryVisible, c
const
handleCreateConversation
=
()
=>
{
const
sessionToolId
=
sessionStorage
.
getItem
(
'currentToolId'
)
||
undefined
dispatch
(
clearBusinessInsightWorkflow
())
dispatch
(
createConversation
({
conversationData
:
{
...(
sessionToolId
?
{
toolId
:
sessionToolId
}
:
{}),
...
...
@@ -150,14 +152,19 @@ const NavbarBase: React.FC<NavbarProps & WithAuthProps> = ({ isHistoryVisible, c
await
waitForToken
()
const
qaRes
=
await
fetchUserQaRecordPage
(
currentConversationId
,
currentToolId
)
const
qaRecords
=
qaRes
?.
data
||
[]
window
.
dispatchEvent
(
new
CustomEvent
(
'toolHistoryLoaded'
,
{
detail
:
{
conversationId
:
currentConversationId
,
toolId
:
currentToolId
,
qaRecords
:
qaRes
?.
data
||
[]
,
qaRecords
,
},
}))
// 最后一条已有 workflow 上下文则不再自动补问;否则静默首包一次
if
(
hasQaWorkflowContext
(
qaRecords
))
return
const
pair
=
getToolQuestionPairFromRow
(
row
)
const
recordTypeForAuto
=
pair
.
recordType
||
'01'
requestAnimationFrame
(()
=>
{
...
...
src/lib/toolRoleRecordTypes.ts
View file @
c877b1a4
...
...
@@ -49,11 +49,16 @@ export function findGeneralToolRow(toolList: unknown): ToolListRow | null {
return
hit
??
null
}
export
function
getFirstToolListRow
(
toolList
:
unknown
):
ToolListRow
|
null
{
const
list
=
asToolRows
(
toolList
)
return
list
.
length
>
0
?
list
[
0
]
:
null
}
export
function
findToolRowBySessionToolId
(
toolList
:
unknown
,
sessionToolId
:
string
|
null
|
undefined
):
ToolListRow
|
null
{
const
list
=
asToolRows
(
toolList
)
const
tid
=
normalizeToolId
(
sessionToolId
)
if
(
!
tid
)
return
findGeneralTool
Row
(
toolList
)
return
getFirstToolList
Row
(
toolList
)
return
list
.
find
(
r
=>
normalizeToolId
(
r
.
toolId
)
===
tid
)
??
null
}
...
...
@@ -71,6 +76,19 @@ export function getToolQuestionPairFromRow(row: ToolListRow | null | undefined):
return
{
toolId
,
recordType
}
}
/**
* 提问接口统一解析 toolId / recordType:按当前会话工具从 get_tool_list 取行,
* recordType 为 userRole 第一段(提问流),无则回退行上 recordType;无 toolId 时取制度活化/通用行。
*/
export
function
resolveSubmitQuestionToolPair
(
sessionToolId
?:
string
|
null
,
toolList
?:
ToolListRow
[],
):
{
toolId
:
string
,
recordType
:
string
}
{
const
list
=
toolList
??
readToolListFromSessionStorage
()
const
row
=
findToolRowBySessionToolId
(
list
,
sessionToolId
)
return
getToolQuestionPairFromRow
(
row
)
}
/** 左侧常见问题 recordType */
export
function
getCommonQuestionRecordTypeForToolRow
(
row
:
ToolListRow
|
null
|
undefined
):
string
{
if
(
!
row
)
...
...
src/lib/workflowToolParams.ts
View file @
c877b1a4
import
{
getToolQuestionPairFromRow
}
from
'@/lib/toolRoleRecordTypes'
import
{
getToolQuestionPairFromRow
,
resolveSubmitQuestionToolPair
}
from
'@/lib/toolRoleRecordTypes'
import
type
{
ToolListRow
}
from
'@/types/toolList'
/**
...
...
@@ -104,16 +104,15 @@ export function getToolListFirstPair(toolList: unknown[]): { toolId: string, rec
* 若已有流式返回缓存则优先;否则使用列表第 0 项的 userRole 首段(首次)。
*/
export
function
getWorkflowToolIdAndRecordTypeForRequest
(
toolList
:
unknown
[]):
{
toolId
:
string
,
recordType
:
string
}
{
const
last
=
getWorkflowLastToolPair
()
if
(
last
)
return
last
const
seed
=
getWorkflowRequestSeedPair
()
if
(
seed
)
return
seed
const
sessionToolId
=
typeof
window
!==
'undefined'
?
sessionStorage
.
getItem
(
'currentToolId'
)
:
null
const
fromList
=
resolveSubmitQuestionToolPair
(
sessionToolId
,
Array
.
isArray
(
toolList
)
?
(
toolList
as
import
(
'@/types/toolList'
).
ToolListRow
[])
:
undefined
)
if
(
fromList
.
recordType
)
return
fromList
return
getToolListFirstPair
(
toolList
)
}
/** Chat 提交:流式最后一次优先,否则为本次点击写入的 seed(get_tool_list[0]) */
export
function
getWorkflowToolIdAndRecordTypeForSubmit
():
{
toolId
:
string
,
recordType
:
string
}
|
null
{
return
getWorkflowLastToolPair
()
??
getWorkflowRequestSeedPair
()
/** Chat 提交:与 resolveSubmitQuestionToolPair 一致(get_tool_list 当前项 userRole 第一段) */
export
function
getWorkflowToolIdAndRecordTypeForSubmit
(
sessionToolId
?:
string
|
null
):
{
toolId
:
string
,
recordType
:
string
}
{
const
fromSession
=
typeof
window
!==
'undefined'
?
sessionStorage
.
getItem
(
'currentToolId'
)
:
null
return
resolveSubmitQuestionToolPair
(
sessionToolId
??
fromSession
??
undefined
)
}
src/pages/Chat/Chat.tsx
View file @
c877b1a4
import
React
,
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
'react'
import
React
,
{
useCallback
,
useEffect
,
use
Memo
,
use
Ref
,
useState
}
from
'react'
import
{
useLocation
,
useParams
,
useSearchParams
}
from
'react-router-dom'
import
{
Button
}
from
'@heroui/react'
import
{
motion
}
from
'framer-motion'
import
{
useScroll
}
from
'ahooks'
import
styles
from
'./Chat.module.less'
import
{
getLastQaRecordWorkflowContext
,
processApiResponse
}
from
'./helper'
import
{
type
WorkflowParseOptions
,
getLastQaRecordWorkflowContext
,
hasQaWorkflowContext
,
hasWorkflowFormCards
,
isHiddenWorkflowRedirectControl
,
isHiddenWorkflowRedirectPayload
,
isWorkflowControlEnd
,
isWorkflowPacketEnd
,
markWorkflowFormSubmittedOnRecord
,
processApiResponse
,
resolveDisplayAnswerText
,
resolveWorkFlowSessionId
,
tryParseWorkflowControlJson
}
from
'./helper'
import
type
{
WorkflowParamMap
}
from
'./lib/workflowCardForm'
import
{
ChatWelcome
}
from
'./components/ChatWelcome'
import
{
ChatItemUser
}
from
'./components/ChatItem/ChatItemUser'
import
{
ChatAnswerBox
}
from
'./components/ChatItem/ChatAnswerBox'
import
{
trimSpacesOnly
}
from
'./components/ChatItem/markdownFormatter'
import
{
ChatEditor
}
from
'@/components/ChatEditor'
import
type
{
Chat
Record
}
from
'@/types/chat'
import
type
{
Answer
,
ChatRecord
,
Original
Record
}
from
'@/types/chat'
import
{
fetchSessionConversationId
,
fetchUserQaRecordPage
}
from
'@/api/conversation'
import
{
fetchCheckTokenApi
,
fetchStreamResponse
}
from
'@/api/chat'
import
{
clearCurrentToolId
,
clearShouldSendQuestion
,
exitBusinessInsightSubWorkflow
,
fetchConversations
,
setBusinessInsightMainWorkflow
,
setBusinessInsightPendingQuestion
,
setBusinessInsightSubWorkflow
,
setCurrentToolId
}
from
'@/store/conversationSlice'
import
{
clearCurrentToolId
,
clearShouldSendQuestion
,
exitBusinessInsightSubWorkflow
,
fetchConversations
,
setBusinessInsightMainWorkflow
,
setBusinessInsightPendingQuestion
,
setBusinessInsightSubWorkflow
,
setCurrentToolId
,
updateBusinessInsightParamMap
}
from
'@/store/conversationSlice'
import
{
getUserRolesForApi
,
safeSessionStorageGetItem
,
waitForToken
}
from
'@/lib/utils'
import
{
find
GeneralToolRow
,
findToolRowBySessionToolId
,
readToolListFromSessionStorage
}
from
'@/lib/toolRoleRecordTypes'
import
{
getWorkflowToolIdAndRecordTypeForSubmit
,
setWorkflowLastToolPair
}
from
'@/lib/workflowToolParams'
import
{
find
ToolRowBySessionToolId
,
getFirstToolListRow
,
readToolListFromSessionStorage
,
resolveSubmitQuestionToolPair
}
from
'@/lib/toolRoleRecordTypes'
import
{
setWorkflowLastToolPair
}
from
'@/lib/workflowToolParams'
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'
type
WorkflowStreamAnswer
=
Answer
&
{
workflowParseText
?:
string
workflowAnswerChunk
?:
string
workflowPacketEnd
?:
boolean
}
const
PENDING_WORKFLOW_SILENT_BOOTSTRAP_KEY
=
'pendingWorkflowSilentBootstrap'
function
makeLocalGroupId
(
prefix
:
string
)
{
try
{
// modern browsers
...
...
@@ -87,7 +96,7 @@ export const Chat: React.FC = () => {
mainWorkflowSessionId
:
string
|
null
subWorkflowSessionId
:
string
|
null
subWorkflowRecordType
:
string
|
null
paramMap
:
Record
<
string
,
string
>
paramMap
:
WorkflowParamMap
isInSubWorkflow
:
boolean
pendingQuestion
:
string
|
null
}
>
({
...
...
@@ -102,6 +111,43 @@ export const Chat: React.FC = () => {
const
expectingSubWorkflowOpenRef
=
useRef
(
false
)
const
pendingSubOpenRecordTypeRef
=
useRef
<
string
|
null
>
(
null
)
const
pendingSubQuestionRef
=
useRef
<
string
|
null
>
(
null
)
/** 当前流式请求过程中最后一条 AI 回答的快照(避免流结束回调读到过期的 allItems) */
const
lastStreamedAiAnswerRef
=
useRef
<
WorkflowStreamAnswer
|
null
>
(
null
)
const
buildWorkflowParseOptions
=
useCallback
((
snapshot
?:
WorkflowStreamAnswer
|
null
,
):
WorkflowParseOptions
|
undefined
=>
{
if
(
!
snapshot
?.
workflowAnswerChunk
&&
!
snapshot
?.
workflowPacketEnd
)
return
undefined
return
{
...(
snapshot
.
workflowAnswerChunk
?
{
answerChunk
:
snapshot
.
workflowAnswerChunk
}
:
{}),
...(
snapshot
.
workflowPacketEnd
?
{
packetEnd
:
true
}
:
{}),
}
},
[])
/** 工作流静默续跑(空问题开子/子流/表单提交):提交前已追加独立 AI 占位,流式勿再覆盖上一条 */
const
workflowSilentAppendAiRef
=
useRef
(
false
)
/** 本次 paramMap 表单提交对应的 AI 记录标识(recordId 或 groupId),流结束后标记 workflowFormSubmitted */
const
pendingWorkflowFormSourceKeyRef
=
useRef
<
string
|
null
>
(
null
)
const
applyLoadedQaWorkflowContext
=
useCallback
((
qaRecords
:
OriginalRecord
[]
|
null
|
undefined
)
=>
{
const
wfCtx
=
getLastQaRecordWorkflowContext
(
qaRecords
)
if
(
wfCtx
.
recordType
&&
wfCtx
.
workFlowSessionId
)
{
lastLoadedQaWorkflowContextRef
.
current
=
{
recordType
:
wfCtx
.
recordType
,
workFlowSessionId
:
wfCtx
.
workFlowSessionId
,
busiType
:
wfCtx
.
busiType
??
'01'
,
}
dispatch
(
setBusinessInsightMainWorkflow
(
wfCtx
.
workFlowSessionId
))
businessInsightWorkflowRef
.
current
=
{
...
businessInsightWorkflowRef
.
current
,
mainWorkflowSessionId
:
wfCtx
.
workFlowSessionId
,
isInSubWorkflow
:
false
,
}
return
true
}
lastLoadedQaWorkflowContextRef
.
current
=
null
return
false
},
[
dispatch
])
// 当外部系统直接以 /chat/:id 链接进入(没有 location.state,且 URL 中也没有 toolId)时,
// 视为一次新的会话入口:重置为制度活化,清除历史遗留的工具模式状态
...
...
@@ -193,6 +239,217 @@ export const Chat: React.FC = () => {
businessInsightWorkflowRef
.
current
=
businessInsightWorkflow
},
[
businessInsightWorkflow
])
type
SubmitQuestionFn
=
(
question
:
string
,
productCode
?:
string
,
toolId
?:
string
,
extraParams
?:
{
paramMap
?:
WorkflowParamMap
,
recordId
?:
string
,
recordType
?:
string
,
workFlowSessionId
?:
string
,
isReask
?:
boolean
,
busiType
?:
string
,
submitViaParamMap
?:
boolean
,
omitWorkFlowSessionId
?:
boolean
},
workFlowSessionId
?:
string
,
recordId
?:
string
,
)
=>
Promise
<
void
>
const
handleSubmitQuestionRef
=
useRef
<
SubmitQuestionFn
|
null
>
(
null
)
const
resolveWorkflowToolId
=
useCallback
(()
=>
{
try
{
return
(
sessionStorage
.
getItem
(
'currentToolId'
)
??
''
).
trim
()
||
(
currentToolId
!=
null
?
String
(
currentToolId
).
trim
()
:
''
)
}
catch
{
return
currentToolId
!=
null
?
String
(
currentToolId
).
trim
()
:
''
}
},
[
currentToolId
])
/** 流式结束后根据最新 answer 驱动主/子工作流(避免读取过期的 allItems 闭包) */
const
processBusinessInsightWorkflowAfterStream
=
useCallback
((
lastAnswer
:
WorkflowStreamAnswer
,
)
=>
{
const
streamSnapshot
=
lastStreamedAiAnswerRef
.
current
const
parseText
=
streamSnapshot
?.
workflowParseText
??
lastAnswer
.
workflowParseText
??
lastAnswer
.
answer
??
''
const
parseOptions
=
buildWorkflowParseOptions
(
streamSnapshot
??
lastAnswer
)
const
parsedAnswer
=
tryParseWorkflowControlJson
(
parseText
,
parseOptions
)
const
lastAnswerSessionId
=
resolveWorkFlowSessionId
(
lastAnswer
)
const
hasHiddenRedirect
=
isHiddenWorkflowRedirectPayload
(
parsedAnswer
)
const
hasEnterSubSignal
=
Boolean
(
parsedAnswer
?.
recordType
!=
null
&&
String
(
parsedAnswer
.
recordType
).
trim
()
&&
parsedAnswer
.
question
!=
null
,
)
const
wf
=
businessInsightWorkflowRef
.
current
const
shouldHandleWorkflowAnswer
=
Boolean
(
lastAnswerSessionId
)
||
Boolean
(
wf
?.
mainWorkflowSessionId
)
||
Boolean
(
wf
?.
isInSubWorkflow
)
||
hasEnterSubSignal
||
hasHiddenRedirect
if
(
!
shouldHandleWorkflowAnswer
)
return
/** showFlag=false:用 answer 内新 recordType + question 静默续问,本条不在页面展示 */
if
(
hasHiddenRedirect
&&
parsedAnswer
)
{
const
hasCards
=
Array
.
isArray
(
lastAnswer
.
cardList
)
&&
lastAnswer
.
cardList
.
length
>
0
// c.txt:任一包 cardList 非空时不得自动续问,需等用户通过卡片提交 paramMap
if
(
hasCards
)
return
const
nextRecordType
=
String
(
parsedAnswer
.
recordType
).
trim
()
const
nextQuestion
=
String
(
parsedAnswer
.
question
??
''
).
trim
()
const
sessionId
=
(
lastAnswerSessionId
||
wf
?.
mainWorkflowSessionId
?.
trim
()
||
lastLoadedQaWorkflowContextRef
.
current
?.
workFlowSessionId
?.
trim
()
||
''
)
if
(
nextRecordType
&&
nextQuestion
)
{
if
(
sessionId
&&
!
wf
?.
mainWorkflowSessionId
)
{
dispatch
(
setBusinessInsightMainWorkflow
(
sessionId
))
businessInsightWorkflowRef
.
current
=
{
...
businessInsightWorkflowRef
.
current
,
mainWorkflowSessionId
:
sessionId
,
isInSubWorkflow
:
false
,
}
}
if
(
sessionId
)
{
lastLoadedQaWorkflowContextRef
.
current
=
{
recordType
:
nextRecordType
,
workFlowSessionId
:
sessionId
,
busiType
:
'01'
,
}
}
queueMicrotask
(()
=>
{
void
handleSubmitQuestionRef
.
current
?.(
nextQuestion
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
recordType
:
nextRecordType
,
busiType
:
'01'
,
...(
sessionId
?
{
workFlowSessionId
:
sessionId
}
:
{}),
})
})
}
return
}
if
(
expectingSubWorkflowOpenRef
.
current
&&
lastAnswerSessionId
)
{
expectingSubWorkflowOpenRef
.
current
=
false
const
subSessionId
=
lastAnswerSessionId
const
subRt
=
(
pendingSubOpenRecordTypeRef
.
current
?.
trim
()
||
(
lastAnswer
.
recordType
!=
null
?
String
(
lastAnswer
.
recordType
).
trim
()
:
''
)
||
''
).
trim
()
pendingSubOpenRecordTypeRef
.
current
=
null
if
(
subRt
&&
subSessionId
)
{
dispatch
(
setBusinessInsightSubWorkflow
({
sessionId
:
subSessionId
,
recordType
:
subRt
,
}))
businessInsightWorkflowRef
.
current
=
{
...
businessInsightWorkflowRef
.
current
,
subWorkflowSessionId
:
subSessionId
,
subWorkflowRecordType
:
subRt
,
isInSubWorkflow
:
true
,
paramMap
:
{},
}
const
hasCards
=
Array
.
isArray
(
lastAnswer
.
cardList
)
&&
lastAnswer
.
cardList
.
length
>
0
const
pq
=
pendingSubQuestionRef
.
current
pendingSubQuestionRef
.
current
=
null
if
(
!
hasCards
&&
pq
&&
pq
.
trim
())
{
queueMicrotask
(()
=>
{
void
handleSubmitQuestionRef
.
current
?.(
pq
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
busiType
:
'01'
,
recordType
:
subRt
,
workFlowSessionId
:
subSessionId
,
})
})
}
}
return
}
if
(
expectingSubWorkflowOpenRef
.
current
)
{
expectingSubWorkflowOpenRef
.
current
=
false
pendingSubOpenRecordTypeRef
.
current
=
null
pendingSubQuestionRef
.
current
=
null
return
}
if
(
parsedAnswer
?.
recordType
!=
null
&&
parsedAnswer
.
question
!=
null
)
{
const
subRecordType
=
String
(
parsedAnswer
.
recordType
).
trim
()
const
pendingQ
=
String
(
parsedAnswer
.
question
)
if
(
subRecordType
)
{
const
mainSessionId
=
(
lastAnswerSessionId
||
businessInsightWorkflowRef
.
current
.
mainWorkflowSessionId
?.
trim
()
||
''
)
if
(
mainSessionId
&&
!
businessInsightWorkflowRef
.
current
.
mainWorkflowSessionId
)
{
dispatch
(
setBusinessInsightMainWorkflow
(
mainSessionId
))
businessInsightWorkflowRef
.
current
=
{
...
businessInsightWorkflowRef
.
current
,
mainWorkflowSessionId
:
mainSessionId
,
isInSubWorkflow
:
false
,
}
}
dispatch
(
setBusinessInsightPendingQuestion
(
pendingQ
))
pendingSubQuestionRef
.
current
=
pendingQ
pendingSubOpenRecordTypeRef
.
current
=
subRecordType
expectingSubWorkflowOpenRef
.
current
=
true
queueMicrotask
(()
=>
{
void
handleSubmitQuestionRef
.
current
?.(
''
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
recordType
:
subRecordType
,
busiType
:
'01'
,
...(
mainSessionId
?
{
workFlowSessionId
:
mainSessionId
}
:
{}),
})
})
}
return
}
if
(
businessInsightWorkflowRef
.
current
.
isInSubWorkflow
)
{
if
(
isWorkflowControlEnd
(
parsedAnswer
?.
end
)
||
parseOptions
?.
packetEnd
)
{
const
reopenRt
=
parsedAnswer
?.
recordType
!=
null
?
String
(
parsedAnswer
.
recordType
).
trim
()
:
''
const
followQ
=
parsedAnswer
?.
question
!=
null
?
String
(
parsedAnswer
.
question
).
trim
()
:
''
const
mainSessionId
=
businessInsightWorkflowRef
.
current
.
mainWorkflowSessionId
?.
trim
()
||
''
dispatch
(
exitBusinessInsightSubWorkflow
())
businessInsightWorkflowRef
.
current
=
{
...
businessInsightWorkflowRef
.
current
,
isInSubWorkflow
:
false
,
subWorkflowSessionId
:
null
,
subWorkflowRecordType
:
null
,
paramMap
:
{},
}
dispatch
(
setBusinessInsightPendingQuestion
(
null
))
queueMicrotask
(()
=>
{
void
handleSubmitQuestionRef
.
current
?.(
''
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
busiType
:
'01'
,
...(
reopenRt
?
{
recordType
:
reopenRt
}
:
{}),
...(
mainSessionId
?
{
workFlowSessionId
:
mainSessionId
}
:
{}),
})
if
(
followQ
)
{
window
.
setTimeout
(()
=>
{
void
handleSubmitQuestionRef
.
current
?.(
followQ
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
busiType
:
'01'
,
...(
mainSessionId
?
{
workFlowSessionId
:
mainSessionId
}
:
{}),
})
},
400
)
}
})
}
return
}
if
(
lastAnswerSessionId
&&
!
businessInsightWorkflowRef
.
current
.
mainWorkflowSessionId
)
{
const
mainSessionId
=
lastAnswerSessionId
if
(
mainSessionId
)
{
dispatch
(
setBusinessInsightMainWorkflow
(
mainSessionId
))
businessInsightWorkflowRef
.
current
=
{
...
businessInsightWorkflowRef
.
current
,
mainWorkflowSessionId
:
mainSessionId
,
isInSubWorkflow
:
false
,
}
}
}
},
[
buildWorkflowParseOptions
,
dispatch
,
resolveWorkflowToolId
])
/** 处理正常stream的数据 */
const
handleStreamMesageData
=
(
msg
:
any
,
question
:
string
)
=>
{
setAllItems
((
prevItems
)
=>
{
...
...
@@ -261,28 +518,51 @@ export const Chat: React.FC = () => {
// 只移除空格和制表符,保留换行符(避免移除末尾的 \n\n)
filteredAnswer
=
trimSpacesOnly
(
filteredAnswer
)
const
packetData
=
msg
.
content
.
data
||
{}
const
answerChunk
=
typeof
packetData
.
answer
===
'string'
?
packetData
.
answer
:
''
const
packetEnd
=
isWorkflowPacketEnd
(
packetData
)
const
streamParseOptions
:
WorkflowParseOptions
|
undefined
=
(
answerChunk
||
packetEnd
)
?
{
...(
answerChunk
?
{
answerChunk
}
:
{}),
...(
packetEnd
?
{
packetEnd
:
true
}
:
{}),
}
:
undefined
filteredAnswer
=
resolveDisplayAnswerText
(
filteredAnswer
,
streamParseOptions
)
// 在流式响应过程中,忽略服务器返回的 endAnswerFlag,避免过早禁用输入框
const
{
endAnswerFlag
:
_omitEndAnswerFlag
,
...
dataWithoutEndFlag
}
=
msg
.
content
.
data
||
{}
const
{
endAnswerFlag
:
_omitEndAnswerFlag
,
...
dataWithoutEndFlag
}
=
packetData
const
chunkToolId
=
dataWithoutEndFlag
.
toolId
!=
null
?
String
(
dataWithoutEndFlag
.
toolId
).
trim
()
:
''
const
chunkRecordType
=
dataWithoutEndFlag
.
recordType
!=
null
?
String
(
dataWithoutEndFlag
.
recordType
).
trim
()
:
''
if
(
chunkToolId
&&
chunkRecordType
)
setWorkflowLastToolPair
(
chunkToolId
,
chunkRecordType
)
const
resolvedSessionId
=
resolveWorkFlowSessionId
(
{
...
existingAnswer
,
...
dataWithoutEndFlag
},
existingAnswer
.
workFlowSessionId
,
)
// 保留之前保存的 workFlowSessionId 和 recordId,避免被覆盖
const
mergedAnswer
:
Answer
=
{
...
existingAnswer
,
...
dataWithoutEndFlag
,
workFlowSessionId
:
resolvedSessionId
,
recordId
:
dataWithoutEndFlag
.
recordId
??
existingAnswer
.
recordId
,
cardList
:
Array
.
isArray
(
dataWithoutEndFlag
.
cardList
)
&&
dataWithoutEndFlag
.
cardList
.
length
>
0
?
dataWithoutEndFlag
.
cardList
:
existingAnswer
.
cardList
,
isShow
:
false
,
answer
:
filteredAnswer
,
}
newItems
[
currentIndex
]
=
{
...
newItems
[
currentIndex
],
question
:
isEmptyQuestion
?
undefined
:
question
,
answerList
:
[
{
...
existingAnswer
,
...
dataWithoutEndFlag
,
// 如果新的数据中没有 workFlowSessionId,保留之前的值
workFlowSessionId
:
dataWithoutEndFlag
.
workFlowSessionId
??
existingAnswer
.
workFlowSessionId
,
// 如果新的数据中没有 recordId,保留之前的值
recordId
:
dataWithoutEndFlag
.
recordId
??
existingAnswer
.
recordId
,
isShow
:
false
,
answer
:
filteredAnswer
,
},
],
answerList
:
[
mergedAnswer
],
}
lastStreamedAiAnswerRef
.
current
=
{
...
mergedAnswer
,
workflowParseText
:
trimSpacesOnly
(
originalAnswer
),
workflowAnswerChunk
:
answerChunk
,
workflowPacketEnd
:
packetEnd
||
lastStreamedAiAnswerRef
.
current
?.
workflowPacketEnd
,
}
}
return
newItems
...
...
@@ -326,28 +606,33 @@ export const Chat: React.FC = () => {
})
}
const
workflowBootstrapDoneRef
=
useRef
<
string
|
null
>
(
null
)
/** 提交问题 */
const
handleSubmitQuestion
=
async
(
question
:
string
,
productCode
?:
string
,
toolId
?:
string
,
extraParams
?:
{
paramMap
?:
Record
<
string
,
string
>
,
recordId
?:
string
,
recordType
?:
string
,
workFlowSessionId
?:
string
,
isReask
?:
boolean
,
busiType
?:
string
},
extraParams
?:
{
paramMap
?:
WorkflowParamMap
,
recordId
?:
string
,
recordType
?:
string
,
workFlowSessionId
?:
string
,
isReask
?:
boolean
,
busiType
?:
string
,
submitViaParamMap
?:
boolean
,
omitWorkFlowSessionId
?:
boolean
},
workFlowSessionId
?:
string
,
recordId
?:
string
,
)
=>
{
// 优先读取缓存中的 toolId,再回退到传参或 Redux
const
sessionToolId
=
sessionStorage
.
getItem
(
'currentToolId'
)
??
undefined
const
resolvedToolId
=
toolId
??
sessionToolId
??
currentToolId
??
undefined
// 业务洞察等工作流参数:有主/子会话态时合并进 extra(不依赖写死 toolId)
const
omitWorkFlowSessionId
=
extraParams
?.
omitWorkFlowSessionId
===
true
// 业务洞察等工作流参数:有主/子会话态时合并进 extra(新建会话首包静默提问不传 session)
let
workflowExtraParams
=
extraParams
if
(
businessInsightWorkflowRef
.
current
)
{
if
(
!
omitWorkFlowSessionId
&&
businessInsightWorkflowRef
.
current
)
{
const
workflowState
=
businessInsightWorkflowRef
.
current
if
(
workflowState
.
isInSubWorkflow
)
{
workflowExtraParams
=
{
...
extraParams
,
recordType
:
workflowState
.
subWorkflowRecordType
||
undefined
,
workFlowSessionId
:
workflowState
.
subWorkflowSessionId
||
workFlowSessionId
,
paramMap
:
workflowState
.
paramMap
,
paramMap
:
extraParams
?.
paramMap
?
{
...
workflowState
.
paramMap
,
...
extraParams
.
paramMap
}
:
workflowState
.
paramMap
,
}
}
else
if
(
workflowState
.
mainWorkflowSessionId
)
{
...
...
@@ -357,12 +642,30 @@ export const Chat: React.FC = () => {
}
}
}
const
submitPairFromList
=
resolveSubmitQuestionToolPair
(
resolvedToolId
)
if
(
!
workflowExtraParams
?.
recordType
&&
submitPairFromList
.
recordType
)
{
workflowExtraParams
=
{
...
workflowExtraParams
,
recordType
:
submitPairFromList
.
recordType
,
}
}
// 空问题 + 显式传入 recordType/busiType(如工具入口 autoSubmit)时走自动欢迎语流式逻辑;
//
已有主会话后的空问题(如「开子工作流」)
不应走欢迎语分支
//
开子工作流、已带 session、或已有主会话的空问题
不应走欢迎语分支
const
isEmptyQuestion
=
!
question
.
trim
()
const
isAutoToolEmptySubmit
=
isEmptyQuestion
&&
Boolean
(
extraParams
?.
recordType
&&
extraParams
?.
busiType
)
const
bootstrapBusiType
=
workflowExtraParams
?.
busiType
??
extraParams
?.
busiType
??
'01'
const
isNewSessionSilentBootstrap
=
isEmptyQuestion
&&
omitWorkFlowSessionId
&&
Boolean
(
workflowExtraParams
?.
recordType
&&
bootstrapBusiType
)
const
isAutoToolEmptySubmit
=
isNewSessionSilentBootstrap
||
(
isEmptyQuestion
&&
Boolean
(
workflowExtraParams
?.
recordType
&&
bootstrapBusiType
)
&&
!
businessInsightWorkflowRef
.
current
.
mainWorkflowSessionId
&&
!
expectingSubWorkflowOpenRef
.
current
&&
!
extraParams
?.
workFlowSessionId
&&
!
workflowExtraParams
?.
workFlowSessionId
)
isAutoSubmitQualityImprovementRef
.
current
=
isAutoToolEmptySubmit
if
(
isAutoSubmitQualityImprovementRef
.
current
)
{
// 重置欢迎语候选
...
...
@@ -374,22 +677,50 @@ export const Chat: React.FC = () => {
abortControllerRef
.
current
.
abort
()
}
const
isParamMapFormSubmit
=
Boolean
(
workflowExtraParams
?.
submitViaParamMap
||
(
workflowExtraParams
?.
paramMap
&&
Object
.
keys
(
workflowExtraParams
.
paramMap
).
length
>
0
),
)
const
isWorkflowSilentSubmit
=
isEmptyQuestion
&&
(
expectingSubWorkflowOpenRef
.
current
||
isParamMapFormSubmit
||
Boolean
(
workflowExtraParams
?.
recordType
&&
(
workflowExtraParams
?.
busiType
||
workflowExtraParams
?.
workFlowSessionId
))
||
businessInsightWorkflowRef
.
current
.
isInSubWorkflow
)
workflowSilentAppendAiRef
.
current
=
isWorkflowSilentSubmit
lastStreamedAiAnswerRef
.
current
=
null
const
isNew
=
allItems
.
length
<=
1
dispatch
(
setIsAsking
(
true
))
// 先展示用户气泡与 AI 占位,避免 check_token 失败时界面毫无反馈且流式接口未发出
if
(
question
.
trim
())
{
// 工作流静默续跑:单独追加 AI 占位,避免覆盖上一条(含 JSON 控制指令)的回答
if
(
isWorkflowSilentSubmit
)
{
setAllItems
(
prevItems
=>
[
...
prevItems
,
{
role
:
'ai'
,
groupId
:
makeLocalGroupId
(
'ai'
),
answerList
:
[{
answer
:
''
,
isShow
:
false
}],
}
as
ChatRecord
,
])
}
// 如果 question 不为空,才添加用户问题和空的 AI 回答(静默提问不展示用户气泡)
if
(
!
isEmptyQuestion
)
{
const
questionText
=
question
.
trim
()
setAllItems
(
prevItems
=>
[
...
prevItems
,
{
role
:
'user'
,
groupId
:
makeLocalGroupId
(
'user'
),
question
,
question
:
questionText
,
}
as
ChatRecord
,
{
role
:
'ai'
,
groupId
:
makeLocalGroupId
(
'ai'
),
answerList
:
[{
answer
:
''
}],
question
:
questionText
,
answerList
:
[{
answer
:
''
,
isShow
:
false
}],
}
as
ChatRecord
,
])
}
...
...
@@ -419,19 +750,19 @@ export const Chat: React.FC = () => {
fetchUrl
=
proxy
+
fetchUrl
// 根据 toolId 添加相应的参数
// 根据 toolId 添加相应的参数
(paramMap 表单提交不传 question)
const
requestBody
:
Record
<
string
,
any
>
=
{
question
,
conversationId
:
currentIdRef
.
current
,
stream
:
true
,
productCode
,
toolId
:
resolvedToolId
??
''
,
}
if
(
!
isParamMapFormSubmit
)
requestBody
.
question
=
question
// 如果是 workflow 提交,添加 paramMap、recordId 和 workFlowSessionId
if
(
workflowExtraParams
?.
paramMap
)
{
// 如果是 workflow
表单
提交,添加 paramMap、recordId 和 workFlowSessionId
if
(
isParamMapFormSubmit
&&
workflowExtraParams
?.
paramMap
)
requestBody
.
paramMap
=
workflowExtraParams
.
paramMap
}
// 优先使用 workflowExtraParams 中的值,其次使用直接传递的参数
if
(
workflowExtraParams
?.
recordId
)
{
requestBody
.
recordId
=
workflowExtraParams
.
recordId
...
...
@@ -439,27 +770,32 @@ export const Chat: React.FC = () => {
else
if
(
recordId
)
{
requestBody
.
recordId
=
recordId
}
// workFlowSessionId 的传递逻辑:
// 1. 如果是重新问答(isReask === true),不传递 workFlowSessionId
// 2. 提质增效等空问题自动调用:未显式传入 workFlowSessionId 时不传(欢迎语首轮);若 extra/参数中已带历史最后一条的 session 则仍传递
// 3. 优先使用 workflowExtraParams 中的值
// 4. 其次使用直接传递的参数
// 5. 若仍没有且非「无显式 session 的提质增效自动调用」,从会话历史中找到最后一个有 workFlowSessionId 的答案
const
isReask
=
workflowExtraParams
?.
isReask
===
true
const
isAutoQualityImprovement
=
isAutoSubmitQualityImprovementRef
.
current
const
explicitWorkFlowSessionId
=
workflowExtraParams
?.
workFlowSessionId
||
workFlowSessionId
const
shouldUseWorkflowSession
=
Boolean
(
resolvedToolId
&&
String
(
resolvedToolId
).
trim
()
!==
''
)
if
(
shouldUseWorkflowSession
&&
!
isReask
)
{
const
explicitWorkFlowSessionId
=
omitWorkFlowSessionId
?
undefined
:
(
workflowExtraParams
?.
workFlowSessionId
||
workFlowSessionId
)
const
workflowRecordType
=
(
workflowExtraParams
?.
recordType
||
submitPairFromList
.
recordType
||
''
).
trim
()
// 有提问流 recordType 即走工作流(含无 toolId 的制度活化),首包 omitWorkFlowSessionId 时不带 session
const
shouldUseWorkflowSession
=
Boolean
(
workflowRecordType
)
&&
!
omitWorkFlowSessionId
&&
!
isReask
if
(
shouldUseWorkflowSession
)
{
let
resolvedWorkflowSessionId
:
string
|
undefined
if
(
explicitWorkFlowSessionId
)
{
resolvedWorkflowSessionId
=
explicitWorkFlowSessionId
}
else
if
(
lastLoadedQaWorkflowContextRef
.
current
?.
workFlowSessionId
)
{
resolvedWorkflowSessionId
=
lastLoadedQaWorkflowContextRef
.
current
.
workFlowSessionId
}
else
if
(
!
isAutoQualityImprovement
)
{
for
(
let
i
=
allItems
.
length
-
1
;
i
>=
0
;
i
--
)
{
const
item
=
allItems
[
i
]
if
(
item
.
role
===
'ai'
&&
item
.
answerList
?.[
0
]?.
workFlowSessionId
)
{
resolvedWorkflowSessionId
=
item
.
answerList
[
0
].
workFlowSessionId
const
fromHistory
=
item
.
role
===
'ai'
?
resolveWorkFlowSessionId
(
item
.
answerList
?.[
0
])
:
undefined
if
(
fromHistory
)
{
resolvedWorkflowSessionId
=
fromHistory
break
}
}
...
...
@@ -469,32 +805,17 @@ export const Chat: React.FC = () => {
}
}
// 如果传入了 recordType,优先使用传入的值(workflow 提交场景)
const
hasSessionTool
=
Boolean
(
resolvedToolId
&&
String
(
resolvedToolId
).
trim
())
requestBody
.
toolId
=
hasSessionTool
?
(
submitPairFromList
.
toolId
||
resolvedToolId
)
:
''
if
(
workflowExtraParams
?.
recordType
)
{
requestBody
.
recordType
=
workflowExtraParams
.
recordType
// 如果同时传入了 busiType,也使用传入的值
if
(
workflowExtraParams
?.
busiType
)
{
if
(
workflowExtraParams
?.
busiType
)
requestBody
.
busiType
=
workflowExtraParams
.
busiType
}
}
// 制度活化:toolId 为空字符串或 undefined
else
if
(
!
resolvedToolId
||
resolvedToolId
===
''
)
{
requestBody
.
toolId
=
''
requestBody
.
busiType
=
'01'
requestBody
.
recordType
=
'01'
}
else
{
const
workflowPair
=
getWorkflowToolIdAndRecordTypeForSubmit
()
if
(
workflowPair
?.
toolId
&&
workflowPair
?.
recordType
)
{
requestBody
.
toolId
=
workflowPair
.
toolId
requestBody
.
busiType
=
workflowExtraParams
?.
busiType
??
extraParams
?.
busiType
??
'01'
requestBody
.
recordType
=
workflowPair
.
recordType
}
else
{
requestBody
.
toolId
=
resolvedToolId
requestBody
.
busiType
=
'01'
requestBody
.
recordType
=
'01'
}
requestBody
.
recordType
=
submitPairFromList
.
recordType
||
'01'
requestBody
.
busiType
=
workflowExtraParams
?.
busiType
??
extraParams
?.
busiType
??
'01'
}
fetchStreamResponse
(
...
...
@@ -568,140 +889,104 @@ export const Chat: React.FC = () => {
isAutoSubmitQualityImprovementRef
.
current
=
false
autoWelcomeCandidateRef
.
current
=
''
hasAutoWelcomeCandidateRef
.
current
=
false
// 设置最后一条 AI 回答的 endAnswerFlag,用于控制打字效果结束
const
streamSnapshotForWf
=
lastStreamedAiAnswerRef
.
current
let
workflowLastAnswer
:
WorkflowStreamAnswer
|
null
=
null
setAllItems
((
prevItems
)
=>
{
const
newItems
=
[...
prevItems
]
// 从后往前找到最后一个 AI 项
const
streamed
=
lastStreamedAiAnswerRef
.
current
for
(
let
i
=
newItems
.
length
-
1
;
i
>=
0
;
i
--
)
{
if
(
newItems
[
i
].
role
===
'ai'
&&
newItems
[
i
].
answerList
?.[
0
])
{
const
prevAnswer
=
newItems
[
i
].
answerList
[
0
].
answer
newItems
[
i
]
=
{
...
newItems
[
i
],
answerList
:
[
{
...
newItems
[
i
].
answerList
[
0
],
const
base
=
newItems
[
i
].
answerList
[
0
]
const
mergedCardList
=
streamed
?.
cardList
??
base
.
cardList
const
hasCards
=
Array
.
isArray
(
mergedCardList
)
&&
mergedCardList
.
length
>
0
const
rawParseText
=
streamed
?.
workflowParseText
??
streamed
?.
answer
??
base
.
answer
??
''
const
streamParseOptions
=
buildWorkflowParseOptions
(
streamed
)
const
isHiddenRedirect
=
isHiddenWorkflowRedirectControl
(
rawParseText
,
streamParseOptions
)
const
displayAnswer
=
resolveDisplayAnswerText
(
(
streamed
?.
answer
??
base
.
answer
)
===
''
?
''
:
(
streamed
?.
answer
??
base
.
answer
),
)
const
merged
:
Answer
=
streamed
?
{
...
base
,
...
streamed
,
answer
:
isHiddenRedirect
?
''
:
(
displayAnswer
===
''
&&
!
hasCards
?
'暂无内容'
:
displayAnswer
),
workFlowSessionId
:
resolveWorkFlowSessionId
(
streamed
,
resolveWorkFlowSessionId
(
base
)),
recordId
:
streamed
.
recordId
??
base
.
recordId
,
recordType
:
streamed
.
recordType
??
base
.
recordType
,
cardList
:
mergedCardList
,
endAnswerFlag
:
true
,
answer
:
prevAnswer
===
''
?
'暂无内容'
:
prevAnswer
,
},
],
// 含工作流表单卡片时保持 Parser,便于流式过程中展示表单;其余 cardList 流结束后走 Shower
isShow
:
isHiddenRedirect
||
(
hasCards
&&
hasWorkflowFormCards
(
mergedCardList
))
?
false
:
(
hasCards
&&
!
base
.
firstQaFlag
&&
!
streamed
?.
firstQaFlag
?
true
:
base
.
isShow
),
}
:
{
...
base
,
endAnswerFlag
:
true
,
answer
:
base
.
answer
===
''
?
'暂无内容'
:
base
.
answer
,
}
workflowLastAnswer
=
{
...
merged
,
workflowParseText
:
rawParseText
,
workflowAnswerChunk
:
streamed
?.
workflowAnswerChunk
,
workflowPacketEnd
:
streamed
?.
workflowPacketEnd
,
}
if
(
isHiddenRedirect
&&
!
hasCards
)
{
newItems
.
splice
(
i
,
1
)
}
else
{
newItems
[
i
]
=
{
...
newItems
[
i
],
answerList
:
[
merged
]
}
}
break
}
}
if
(
!
workflowLastAnswer
&&
streamed
)
{
workflowLastAnswer
=
{
...
streamed
,
answer
:
streamed
.
answer
||
'暂无内容'
,
isShow
:
false
,
cardList
:
streamed
.
cardList
??
[],
endAnswerFlag
:
true
,
}
}
const
formSourceKey
=
pendingWorkflowFormSourceKeyRef
.
current
if
(
formSourceKey
)
return
markWorkflowFormSubmittedOnRecord
(
newItems
,
formSourceKey
)
return
newItems
})
pendingWorkflowFormSourceKeyRef
.
current
=
null
dispatch
(
setIsAsking
(
false
))
workflowSilentAppendAiRef
.
current
=
false
const
wfParseText
=
streamSnapshotForWf
?.
workflowParseText
??
''
const
wfParseOpts
=
buildWorkflowParseOptions
(
streamSnapshotForWf
)
const
wfParsedCtrl
=
tryParseWorkflowControlJson
(
wfParseText
,
wfParseOpts
)
const
wfCtxRecordType
=
wfParsedCtrl
?.
recordType
!=
null
?
String
(
wfParsedCtrl
.
recordType
).
trim
()
:
(
streamSnapshotForWf
?.
recordType
!=
null
?
String
(
streamSnapshotForWf
.
recordType
).
trim
()
:
''
)
const
wfCtxSessionId
=
resolveWorkFlowSessionId
(
streamSnapshotForWf
)
if
(
wfCtxRecordType
&&
wfCtxSessionId
)
{
lastLoadedQaWorkflowContextRef
.
current
=
{
recordType
:
wfCtxRecordType
,
workFlowSessionId
:
wfCtxSessionId
,
busiType
:
streamSnapshotForWf
?.
busiType
!=
null
?
String
(
streamSnapshotForWf
.
busiType
).
trim
()
:
'01'
,
}
}
if
(
workflowLastAnswer
)
{
queueMicrotask
(()
=>
{
processBusinessInsightWorkflowAfterStream
(
workflowLastAnswer
!
)
lastStreamedAiAnswerRef
.
current
=
null
})
}
else
{
lastStreamedAiAnswerRef
.
current
=
null
}
if
(
isNew
)
{
setTimeout
(()
=>
{
dispatch
(
fetchConversations
())
},
2000
)
}
// 业务洞察工作流处理:检查 AI 返回的 answer 是否需要开启子工作流
const
currentItems
=
allItems
let
lastAiItem
=
null
for
(
let
i
=
currentItems
.
length
-
1
;
i
>=
0
;
i
--
)
{
if
(
currentItems
[
i
].
role
===
'ai'
&&
currentItems
[
i
].
answerList
?.[
0
])
{
lastAiItem
=
currentItems
[
i
]
break
}
}
if
(
lastAiItem
)
{
const
lastAnswer
=
lastAiItem
.
answerList
?.[
0
]
const
wf
=
businessInsightWorkflowRef
.
current
const
shouldHandleWorkflowAnswer
=
Boolean
(
lastAnswer
?.
workFlowSessionId
)
||
Boolean
(
wf
?.
mainWorkflowSessionId
)
||
Boolean
(
wf
?.
isInSubWorkflow
)
if
(
lastAnswer
&&
shouldHandleWorkflowAnswer
)
{
const
resolveWorkflowToolId
=
()
=>
{
try
{
return
(
sessionStorage
.
getItem
(
'currentToolId'
)
??
''
).
trim
()
||
(
currentToolId
!=
null
?
String
(
currentToolId
).
trim
()
:
''
)
}
catch
{
return
currentToolId
!=
null
?
String
(
currentToolId
).
trim
()
:
''
}
}
// 刚结束的流是「开子工作流」空问题请求:写入子 session,无卡片则自动「问子」
if
(
expectingSubWorkflowOpenRef
.
current
&&
lastAnswer
.
workFlowSessionId
)
{
expectingSubWorkflowOpenRef
.
current
=
false
const
subRt
=
(
pendingSubOpenRecordTypeRef
.
current
?.
trim
()
||
(
lastAnswer
.
recordType
!=
null
?
String
(
lastAnswer
.
recordType
).
trim
()
:
''
)
||
''
).
trim
()
pendingSubOpenRecordTypeRef
.
current
=
null
if
(
subRt
)
{
dispatch
(
setBusinessInsightSubWorkflow
({
sessionId
:
String
(
lastAnswer
.
workFlowSessionId
),
recordType
:
subRt
,
}))
const
hasCards
=
Array
.
isArray
(
lastAnswer
.
cardList
)
&&
lastAnswer
.
cardList
.
length
>
0
const
pq
=
pendingSubQuestionRef
.
current
pendingSubQuestionRef
.
current
=
null
if
(
!
hasCards
&&
pq
&&
pq
.
trim
())
{
queueMicrotask
(()
=>
{
void
handleSubmitQuestion
(
pq
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
busiType
:
'01'
})
})
}
}
}
else
if
(
expectingSubWorkflowOpenRef
.
current
)
{
// 开子未带回 session,避免状态卡死
expectingSubWorkflowOpenRef
.
current
=
false
pendingSubOpenRecordTypeRef
.
current
=
null
pendingSubQuestionRef
.
current
=
null
}
else
{
let
parsedAnswer
:
{
recordType
?:
unknown
,
question
?:
unknown
,
end
?:
unknown
}
|
null
=
null
try
{
parsedAnswer
=
JSON
.
parse
(
lastAnswer
.
answer
||
''
)
}
catch
{
// 不是 JSON,保持正常回答
}
// 主工作流返回进子 JSON:先空问题开子(带主 session),再在流结束后写入子 session
if
(
parsedAnswer
&&
parsedAnswer
.
recordType
&&
parsedAnswer
.
question
!=
null
)
{
const
subRecordType
=
String
(
parsedAnswer
.
recordType
).
trim
()
const
pendingQ
=
String
(
parsedAnswer
.
question
)
if
(
subRecordType
)
{
dispatch
(
setBusinessInsightPendingQuestion
(
pendingQ
))
pendingSubQuestionRef
.
current
=
pendingQ
pendingSubOpenRecordTypeRef
.
current
=
subRecordType
expectingSubWorkflowOpenRef
.
current
=
true
queueMicrotask
(()
=>
{
void
handleSubmitQuestion
(
''
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
recordType
:
subRecordType
,
busiType
:
'01'
,
})
})
}
}
else
if
(
businessInsightWorkflowRef
.
current
.
isInSubWorkflow
)
{
if
(
parsedAnswer
&&
parsedAnswer
.
end
===
true
)
{
const
reopenRt
=
parsedAnswer
.
recordType
!=
null
?
String
(
parsedAnswer
.
recordType
).
trim
()
:
''
const
followQ
=
parsedAnswer
.
question
!=
null
?
String
(
parsedAnswer
.
question
).
trim
()
:
''
dispatch
(
exitBusinessInsightSubWorkflow
())
dispatch
(
setBusinessInsightPendingQuestion
(
null
))
queueMicrotask
(()
=>
{
void
handleSubmitQuestion
(
''
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
busiType
:
'01'
,
...(
reopenRt
?
{
recordType
:
reopenRt
}
:
{}),
})
if
(
followQ
)
{
window
.
setTimeout
(()
=>
{
void
handleSubmitQuestion
(
followQ
,
undefined
,
resolveWorkflowToolId
()
||
undefined
,
{
busiType
:
'01'
})
},
400
)
}
})
}
}
else
if
(
lastAnswer
.
workFlowSessionId
&&
!
businessInsightWorkflowRef
.
current
.
mainWorkflowSessionId
)
{
dispatch
(
setBusinessInsightMainWorkflow
(
lastAnswer
.
workFlowSessionId
))
}
}
}
}
})
.
catch
(()
=>
{
// 流式请求出错时的处理
...
...
@@ -731,11 +1016,88 @@ export const Chat: React.FC = () => {
return
newItems
})
dispatch
(
setIsAsking
(
false
))
workflowSilentAppendAiRef
.
current
=
false
pendingWorkflowFormSourceKeyRef
.
current
=
null
lastStreamedAiAnswerRef
.
current
=
null
})
}
/** 处理 workflow 表单提交 */
const
handleWorkflowSubmit
=
useCallback
(
async
(
paramMap
:
Record
<
string
,
string
>
,
recordId
?:
string
,
workFlowSessionId
?:
string
)
=>
{
handleSubmitQuestionRef
.
current
=
handleSubmitQuestion
/** c.txt:拉 QA 后无 workflow 上下文则静默首包;制度活化(无 toolId)与有 tool 一致 */
const
runWorkflowSilentBootstrapIfNeeded
=
(
conversationId
:
string
,
sessionToolId
?:
string
|
null
,
qaRecords
?:
OriginalRecord
[]
|
null
,
)
=>
{
if
(
workflowBootstrapDoneRef
.
current
===
conversationId
)
return
const
qaList
=
Array
.
isArray
(
qaRecords
)
?
qaRecords
:
[]
if
(
hasQaWorkflowContext
(
qaList
))
return
const
wf
=
lastLoadedQaWorkflowContextRef
.
current
if
(
wf
?.
recordType
&&
wf
?.
workFlowSessionId
)
return
const
pair
=
resolveSubmitQuestionToolPair
(
sessionToolId
??
''
)
if
(
!
pair
.
recordType
)
return
workflowBootstrapDoneRef
.
current
=
conversationId
queueMicrotask
(()
=>
{
if
(
currentIdRef
.
current
!==
conversationId
)
return
handleSubmitQuestion
(
''
,
undefined
,
sessionToolId
||
undefined
,
{
recordType
:
pair
.
recordType
,
busiType
:
'01'
,
omitWorkFlowSessionId
:
true
,
})
})
}
const
consumePendingWorkflowSilentBootstrap
=
(
conversationId
:
string
)
=>
{
try
{
const
raw
=
sessionStorage
.
getItem
(
PENDING_WORKFLOW_SILENT_BOOTSTRAP_KEY
)
if
(
!
raw
)
return
const
pending
=
JSON
.
parse
(
raw
)
as
{
conversationId
?:
string
toolId
?:
string
recordType
?:
string
busiType
?:
string
}
if
(
pending
.
conversationId
!==
conversationId
)
return
sessionStorage
.
removeItem
(
PENDING_WORKFLOW_SILENT_BOOTSTRAP_KEY
)
runWorkflowSilentBootstrapIfNeeded
(
conversationId
,
pending
.
toolId
??
''
,
null
,
)
}
catch
{
sessionStorage
.
removeItem
(
PENDING_WORKFLOW_SILENT_BOOTSTRAP_KEY
)
}
}
/** 处理 workflow 表单提交:paramMap 提交不传 question */
const
handleWorkflowSubmit
=
useCallback
(
async
(
paramMap
:
WorkflowParamMap
,
recordId
?:
string
,
workFlowSessionId
?:
string
)
=>
{
dispatch
(
updateBusinessInsightParamMap
(
paramMap
))
if
(
businessInsightWorkflowRef
.
current
.
isInSubWorkflow
)
{
businessInsightWorkflowRef
.
current
=
{
...
businessInsightWorkflowRef
.
current
,
paramMap
:
{
...
businessInsightWorkflowRef
.
current
.
paramMap
,
...
paramMap
},
}
}
let
sourceKey
=
recordId
if
(
!
sourceKey
)
{
for
(
let
i
=
allItems
.
length
-
1
;
i
>=
0
;
i
--
)
{
const
item
=
allItems
[
i
]
if
(
item
.
role
===
'ai'
&&
hasWorkflowFormCards
(
item
.
answerList
?.[
0
]?.
cardList
))
{
sourceKey
=
item
.
answerList
?.[
0
]?.
recordId
||
item
.
groupId
break
}
}
}
pendingWorkflowFormSourceKeyRef
.
current
=
sourceKey
!=
null
?
String
(
sourceKey
)
:
null
const
recordType
=
businessInsightWorkflowRef
.
current
.
subWorkflowRecordType
?.
trim
()
||
'A01'
await
handleSubmitQuestion
(
''
,
undefined
,
undefined
,
{
paramMap
,
...
...
@@ -743,8 +1105,23 @@ export const Chat: React.FC = () => {
workFlowSessionId
,
recordType
,
busiType
:
'01'
,
submitViaParamMap
:
true
,
})
},
[])
},
[
allItems
,
dispatch
])
/** 最后一条 AI 含未提交的工作流表单时,禁用底部输入框 */
const
disableChatEditorForWorkflowForm
=
useMemo
(()
=>
{
for
(
let
i
=
allItems
.
length
-
1
;
i
>=
0
;
i
--
)
{
const
item
=
allItems
[
i
]
if
(
item
.
role
!==
'ai'
)
continue
const
answer
=
item
.
answerList
?.[
0
]
if
(
!
answer
)
return
false
return
hasWorkflowFormCards
(
answer
.
cardList
)
&&
!
answer
.
workflowFormSubmitted
}
return
false
},
[
allItems
])
/** 获取qa记录 */
const
getUserQaRecordPage
=
useCallback
(
async
(
conversationId
:
string
,
toolId
?:
string
)
=>
{
...
...
@@ -799,14 +1176,7 @@ export const Chat: React.FC = () => {
await
waitForToken
()
const
res
=
await
fetchUserQaRecordPage
(
finalConversationId
,
toolId
||
safeSessionStorageGetItem
(
'currentToolId'
)
||
''
)
const
qaRecords
=
res
.
data
||
[]
const
wfCtx
=
getLastQaRecordWorkflowContext
(
qaRecords
)
lastLoadedQaWorkflowContextRef
.
current
=
wfCtx
.
recordType
&&
wfCtx
.
workFlowSessionId
?
{
recordType
:
wfCtx
.
recordType
,
workFlowSessionId
:
wfCtx
.
workFlowSessionId
,
busiType
:
wfCtx
.
busiType
??
'01'
,
}
:
null
applyLoadedQaWorkflowContext
(
qaRecords
)
const
messages
=
[{
role
:
'system'
}
as
ChatRecord
,
...
processApiResponse
(
qaRecords
)]
// 处理历史记录中的参考文档标记
const
processedMessages
=
messages
.
map
((
item
)
=>
{
...
...
@@ -919,8 +1289,8 @@ export const Chat: React.FC = () => {
setTimeout
(()
=>
{
// 左侧常见问题自动发送:若 QA 列表最后一条带 recordType + workFlowSessionId,一并透传
const
wfExtra
=
lastLoadedQaWorkflowContextRef
.
current
const
autoExtra
=
wfExtra
?.
recordType
&&
wfExtra
?.
workFlowSessionId
?
{
recordType
:
wfExtra
.
recordType
,
workFlowSessionId
:
wfExtra
.
workFlowSessionId
,
busiType
:
wfExtra
.
busiType
??
'01'
}
const
autoExtra
=
wfExtra
?.
workFlowSessionId
?
{
workFlowSessionId
:
wfExtra
.
workFlowSessionId
,
busiType
:
wfExtra
.
busiType
??
'01'
}
:
undefined
handleSubmitQuestion
(
questionToSend
,
undefined
,
currentToolId
,
autoExtra
)
},
100
)
...
...
@@ -951,6 +1321,7 @@ export const Chat: React.FC = () => {
const
toolHistoryLoadingId
=
sessionStorage
.
getItem
(
'toolHistoryLoading'
)
if
(
toolHistoryLoadingId
===
id
)
{
currentIdRef
.
current
=
id
workflowBootstrapDoneRef
.
current
=
null
// 更新 toolId 相关状态(如果有)
if
(
initialToolId
!==
undefined
)
{
toolIdFromStateRef
.
current
=
initialToolId
...
...
@@ -987,6 +1358,7 @@ export const Chat: React.FC = () => {
dispatch
(
setIsAsking
(
false
))
}
currentIdRef
.
current
=
id
workflowBootstrapDoneRef
.
current
=
null
lastSentQuestionRef
.
current
=
''
// 重置标记
pendingQuestionRef
.
current
=
null
// 清除待发送的问题
// 重置历史记录加载标记,确保新会话时能正确触发自动发送
...
...
@@ -1007,6 +1379,22 @@ export const Chat: React.FC = () => {
}
},
[
id
,
location
.
state
])
// 工具切换后若 toolHistoryLoaded 未送达(如页面晚挂载),消费 pending 触发静默首包
useEffect
(()
=>
{
if
(
!
id
)
return
const
timer
=
window
.
setTimeout
(()
=>
{
if
(
toolHistoryLoadedRef
.
current
.
conversationId
===
id
||
historyLoadedRef
.
current
.
conversationId
===
id
)
{
return
}
consumePendingWorkflowSilentBootstrap
(
id
)
},
250
)
return
()
=>
window
.
clearTimeout
(
timer
)
},
[
id
])
// 处理shouldSendQuestion的变化 - 自动发送问题
useEffect
(()
=>
{
if
(
...
...
@@ -1034,8 +1422,8 @@ export const Chat: React.FC = () => {
setTimeout
(()
=>
{
console
.
log
(
'[Chat] 执行自动发送问题:'
,
questionToSend
)
const
wfExtra
=
lastLoadedQaWorkflowContextRef
.
current
const
autoExtra
=
wfExtra
?.
recordType
&&
wfExtra
?.
workFlowSessionId
?
{
recordType
:
wfExtra
.
recordType
,
workFlowSessionId
:
wfExtra
.
workFlowSessionId
,
busiType
:
wfExtra
.
busiType
??
'01'
}
const
autoExtra
=
wfExtra
?.
workFlowSessionId
?
{
workFlowSessionId
:
wfExtra
.
workFlowSessionId
,
busiType
:
wfExtra
.
busiType
??
'01'
}
:
undefined
handleSubmitQuestion
(
questionToSend
,
undefined
,
currentToolId
,
autoExtra
)
},
100
)
...
...
@@ -1083,8 +1471,8 @@ export const Chat: React.FC = () => {
}
else
{
const
list
=
readToolListFromSessionStorage
()
const
general
=
findGeneralTool
Row
(
list
)
setCurrentToolName
(
general
?.
toolName
?.
trim
()
||
undefined
)
const
first
=
getFirstToolList
Row
(
list
)
setCurrentToolName
(
first
?.
toolName
?.
trim
()
||
undefined
)
}
}
getToolNameFromToolId
()
...
...
@@ -1117,17 +1505,10 @@ export const Chat: React.FC = () => {
useEffect
(()
=>
{
const
handleToolHistoryLoaded
=
(
event
:
CustomEvent
)
=>
{
const
{
conversationId
,
toolId
,
toolName
,
qaRecords
}
=
event
.
detail
const
qaList
=
Array
.
isArray
(
qaRecords
)
?
qaRecords
:
[]
setIsLoading
(
true
)
try
{
const
qaList
=
Array
.
isArray
(
qaRecords
)
?
qaRecords
:
[]
const
wfEvt
=
getLastQaRecordWorkflowContext
(
qaList
)
lastLoadedQaWorkflowContextRef
.
current
=
wfEvt
.
recordType
&&
wfEvt
.
workFlowSessionId
?
{
recordType
:
wfEvt
.
recordType
,
workFlowSessionId
:
wfEvt
.
workFlowSessionId
,
busiType
:
wfEvt
.
busiType
??
'01'
,
}
:
null
applyLoadedQaWorkflowContext
(
qaList
)
// 标记已通过事件加载数据,避免 useEffect 重复调用接口
toolHistoryLoadedRef
.
current
.
conversationId
=
conversationId
// 更新当前会话ID
...
...
@@ -1174,6 +1555,8 @@ export const Chat: React.FC = () => {
if
(
toolHistoryLoadingId
===
conversationId
)
{
sessionStorage
.
removeItem
(
'toolHistoryLoading'
)
}
sessionStorage
.
removeItem
(
PENDING_WORKFLOW_SILENT_BOOTSTRAP_KEY
)
runWorkflowSilentBootstrapIfNeeded
(
conversationId
,
toolId
??
''
,
qaList
)
}
}
window
.
addEventListener
(
'toolHistoryLoaded'
,
handleToolHistoryLoaded
as
EventListener
)
...
...
@@ -1182,29 +1565,26 @@ export const Chat: React.FC = () => {
}
},
[
dispatch
])
// 监听自动提问事件(
提质增效模式下使用
)
// 监听自动提问事件(
左侧新建会话 / 工具首包等静默提问
)
useEffect
(()
=>
{
const
handleAutoSubmitQuestion
=
(
event
:
CustomEvent
)
=>
{
const
{
conversationId
,
question
,
toolId
,
recordType
,
busiType
}
=
event
.
detail
const
{
conversationId
,
toolId
,
recordType
,
busiType
}
=
event
.
detail
// 确保当前会话ID匹配
if
(
conversationId
===
currentIdRef
.
current
)
{
const
wf
=
lastLoadedQaWorkflowContextRef
.
current
if
(
wf
?.
recordType
&&
wf
?.
workFlowSessionId
)
{
handleSubmitQuestion
(
question
,
undefined
,
toolId
,
{
recordType
:
wf
.
recordType
,
workFlowSessionId
:
wf
.
workFlowSessionId
,
busiType
:
wf
.
busiType
??
busiType
??
'01'
,
})
}
else
{
handleSubmitQuestion
(
question
,
undefined
,
toolId
,
{
recordType
,
busiType
})
}
runWorkflowSilentBootstrapIfNeeded
(
conversationId
,
toolId
??
''
,
null
)
}
else
{
console
.
warn
(
'[Chat] conversationId 不匹配,跳过自动提交'
,
{
eventConversationId
:
conversationId
,
currentIdRef
:
currentIdRef
.
current
,
})
try
{
sessionStorage
.
setItem
(
PENDING_WORKFLOW_SILENT_BOOTSTRAP_KEY
,
JSON
.
stringify
({
conversationId
,
toolId
:
toolId
??
''
,
recordType
,
busiType
:
busiType
??
'01'
,
}))
}
catch
{
// ignore
}
}
}
window
.
addEventListener
(
'autoSubmitQuestion'
,
handleAutoSubmitQuestion
as
EventListener
)
...
...
@@ -1240,20 +1620,24 @@ export const Chat: React.FC = () => {
?
`${record.role}-${recordId}`
:
`${record.role}-${index}`
// 如果历史记录中 firstQaFlag 为 true,隐藏 UI 但保留组件渲染(确保自动提交逻辑能执行)
const
shouldHideHistoryItem
=
record
.
role
===
'ai'
&&
record
.
answerList
?.[
0
]?.
firstQaFlag
===
true
// 如果是自动调用提问接口(空问题且是流式返回的新记录),不显示问题和回答
// 判断条件:question === undefined(空问题)且 isShow === false(流式返回的新记录,不是历史记录)
const
shouldHideEmptyQuestionAnswer
=
record
.
role
===
'ai'
&&
record
.
question
===
undefined
&&
record
.
answerList
?.[
0
]?.
isShow
===
false
// 白名单:当同一条 AI 记录里同时有 question + answer(且 firstQaFlag 不为 true)时,强制展示,避免误隐藏
const
hasQuestion
=
typeof
record
.
question
===
'string'
&&
record
.
question
.
trim
().
length
>
0
const
questionText
=
typeof
record
.
question
===
'string'
?
record
.
question
.
trim
()
:
''
const
firstAnswer
=
record
.
answerList
?.[
0
]
const
hasAnswer
=
typeof
firstAnswer
?.
answer
===
'string'
&&
firstAnswer
.
answer
.
trim
().
length
>
0
const
hasCardList
=
Array
.
isArray
(
firstAnswer
?.
cardList
)
&&
firstAnswer
!
.
cardList
.
length
>
0
const
hasQuestion
=
questionText
.
length
>
0
// firstQaFlag 且无 cardList:隐藏 UI 但保留挂载(静默自动提问);有 cardList 时必须展示表单/卡片
const
shouldHideHistoryItem
=
record
.
role
===
'ai'
&&
firstAnswer
?.
firstQaFlag
===
true
&&
!
hasCardList
// 静默提问(空问题流式占位且尚无内容):隐藏占位
const
shouldHideEmptyQuestionAnswer
=
record
.
role
===
'ai'
&&
!
hasQuestion
&&
firstAnswer
?.
isShow
===
false
&&
!
hasAnswer
&&
!
hasCardList
const
shouldForceShow
=
record
.
role
===
'ai'
&&
hasQuestion
&&
(
hasAnswer
||
hasCardList
)
&&
firstAnswer
?.
firstQaFlag
!==
true
&&
(
hasCardList
||
(
hasQuestion
&&
(
hasAnswer
||
hasCardList
)))
return
(
<
div
className=
"w-full chatItem mx-auto"
...
...
@@ -1298,7 +1682,14 @@ export const Chat: React.FC = () => {
</
motion
.
div
>
</
div
>
<
ChatEditor
onSubmit=
{
(
question
,
toolId
)
=>
handleSubmitQuestion
(
question
,
undefined
,
toolId
)
}
inputDisabled=
{
disableChatEditorForWorkflowForm
}
onSubmit=
{
(
question
,
toolId
)
=>
{
const
wf
=
lastLoadedQaWorkflowContextRef
.
current
const
extra
=
wf
?.
workFlowSessionId
?
{
workFlowSessionId
:
wf
.
workFlowSessionId
,
busiType
:
wf
.
busiType
??
'01'
}
:
undefined
handleSubmitQuestion
(
question
,
undefined
,
toolId
,
extra
)
}
}
onToolClick=
{
(
isToolBtn
,
toolId
,
toolName
,
shouldChangeStyle
,
conversationId
,
isLoading
,
toolMeta
)
=>
{
// 如果传递了 loading 状态,更新 isLoading
if
(
isLoading
!==
undefined
)
{
...
...
src/pages/Chat/components/ChatItem/CardWorkflowField.tsx
View file @
c877b1a4
import
React
from
'react'
import
{
Input
}
from
'@heroui/react'
import
{
WORKFLOW_FIELD_TYPE
,
type
WorkflowFieldOption
,
type
WorkflowFormField
,
}
from
'../../lib/workflowCardForm'
import
{
CardWorkflowFileField
}
from
'./CardWorkflowFileField'
export
interface
WorkflowFieldOption
{
id
?:
string
text
?:
string
type
?:
string
}
export
interface
WorkflowFieldItem
{
key
:
string
label
:
string
type
:
string
required
?:
boolean
multiple
?:
boolean
options
?:
WorkflowFieldOption
[]
}
export
type
{
WorkflowFieldOption
,
WorkflowFormField
as
WorkflowFieldItem
}
interface
CardWorkflowFieldProps
{
field
:
WorkflowF
ieldItem
field
:
WorkflowF
ormField
value
:
string
|
string
[]
disabled
:
boolean
isUploading
?:
boolean
showRequiredError
?:
boolean
onChange
:
(
key
:
string
,
value
:
string
|
string
[])
=>
void
onPickFile
?:
(
key
:
string
,
file
:
File
)
=>
void
|
Promise
<
void
>
}
export
const
CardWorkflowField
:
React
.
FC
<
CardWorkflowFieldProps
>
=
({
field
,
value
,
disabled
,
showRequiredError
=
false
,
isUploading
=
false
,
onChange
,
onPickFile
,
})
=>
{
const
requiredMark
=
field
.
required
?
'*'
:
''
const
label
=
`
${
requiredMark
}${
field
.
label
}
`
if
(
field
.
type
===
'select'
)
{
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
FILE
)
{
const
fileName
=
typeof
value
===
'string'
?
value
:
''
return
(
<
CardWorkflowFileField
field=
{
field
}
fileName=
{
fileName
}
disabled=
{
disabled
}
isUploading=
{
isUploading
}
showRequiredError=
{
showRequiredError
}
onPickFile=
{
(
file
)
=>
{
void
onPickFile
?.(
field
.
key
,
file
)
}
}
/>
)
}
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
SELECT
)
{
if
(
field
.
multiple
)
{
const
selectedValues
=
Array
.
isArray
(
value
)
?
value
:
[]
return
(
...
...
@@ -54,6 +69,12 @@ export const CardWorkflowField: React.FC<CardWorkflowFieldProps> = ({
</
option
>
))
}
</
select
>
{
showRequiredError
&&
(
<
p
className=
"text-[12px] text-[#f31260] mt-[6px]"
>
请选择
{
field
.
label
}
</
p
>
)
}
</
div
>
)
}
...
...
@@ -78,18 +99,33 @@ export const CardWorkflowField: React.FC<CardWorkflowFieldProps> = ({
</
option
>
))
}
</
select
>
{
showRequiredError
&&
(
<
p
className=
"text-[12px] text-[#f31260] mt-[6px]"
>
请选择
{
field
.
label
}
</
p
>
)
}
</
div
>
)
}
return
(
<
Input
label=
{
label
}
value=
{
typeof
value
===
'string'
?
value
:
''
}
onChange=
{
e
=>
onChange
(
field
.
key
,
e
.
target
.
value
)
}
isDisabled=
{
disabled
}
variant=
"bordered"
className=
"w-full"
/>
<
div
>
<
Input
label=
{
label
}
value=
{
typeof
value
===
'string'
?
value
:
''
}
onChange=
{
e
=>
onChange
(
field
.
key
,
e
.
target
.
value
)
}
isDisabled=
{
disabled
}
variant=
"bordered"
className=
"w-full"
placeholder=
{
field
.
label
?
`请输入${field.label.replace(/(可选)|\(可选\)/g, '').trim()}…`
:
undefined
}
/>
{
showRequiredError
&&
(
<
p
className=
"text-[12px] text-[#f31260] mt-[6px]"
>
请填写
{
field
.
label
}
</
p
>
)
}
</
div
>
)
}
src/pages/Chat/components/ChatItem/CardWorkflowFileField.tsx
0 → 100644
View file @
c877b1a4
import
React
,
{
useRef
}
from
'react'
import
{
Button
}
from
'@heroui/react'
import
type
{
WorkflowFormField
}
from
'../../lib/workflowCardForm'
import
{
getWorkflowFieldValidationMessage
}
from
'../../lib/workflowCardForm'
interface
CardWorkflowFileFieldProps
{
field
:
WorkflowFormField
fileName
:
string
disabled
:
boolean
isUploading
?:
boolean
showRequiredError
:
boolean
onPickFile
:
(
file
:
File
)
=>
void
}
export
const
CardWorkflowFileField
:
React
.
FC
<
CardWorkflowFileFieldProps
>
=
({
field
,
fileName
,
disabled
,
isUploading
=
false
,
showRequiredError
,
onPickFile
,
})
=>
{
const
inputRef
=
useRef
<
HTMLInputElement
>
(
null
)
const
label
=
field
.
required
?
`*
${
field
.
label
}
`
:
field
.
label
const
displayName
=
fileName
.
trim
()
||
'未选择文件'
const
handleFileChange
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
e
.
target
.
files
?.[
0
]
if
(
file
)
onPickFile
(
file
)
e
.
target
.
value
=
''
}
return
(
<
div
>
<
label
className=
"block text-[14px] text-[#4b5563] mb-[6px]"
>
{
label
}
</
label
>
<
div
className=
"flex items-center gap-[12px] flex-wrap"
>
<
span
className=
"text-[14px] text-[#6b7280] truncate max-w-full"
>
{
displayName
}
</
span
>
<
input
ref=
{
inputRef
}
type=
"file"
className=
"hidden"
disabled=
{
disabled
}
onChange=
{
handleFileChange
}
/>
<
Button
size=
"sm"
variant=
"bordered"
color=
"primary"
isDisabled=
{
disabled
||
isUploading
}
isLoading=
{
isUploading
}
onPress=
{
()
=>
inputRef
.
current
?.
click
()
}
>
上传文件
</
Button
>
</
div
>
{
showRequiredError
&&
(
<
p
className=
"text-[12px] text-[#f31260] mt-[6px]"
>
{
getWorkflowFieldValidationMessage
(
field
)
}
</
p
>
)
}
</
div
>
)
}
src/pages/Chat/components/ChatItem/CardWorkflowForm.tsx
View file @
c877b1a4
import
React
,
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
import
{
Button
}
from
'@heroui/react'
import
{
useSearchParams
}
from
'react-router-dom'
import
{
CardWorkflowField
,
type
WorkflowFieldItem
}
from
'./CardWorkflowField'
import
{
WORKFLOW_FIELD_TYPE
,
type
WorkflowFileSelection
,
type
WorkflowFormValues
,
type
WorkflowParamMap
,
buildWorkflowParamMapAsync
,
createInitialWorkflowFileSelections
,
createInitialWorkflowFormValues
,
extractUploadUrlFromResponse
,
getWorkflowRequiredMissingFields
,
isWorkflowFieldFilled
,
parseWorkflowFormFields
,
}
from
'../../lib/workflowCardForm'
import
{
CardWorkflowField
}
from
'./CardWorkflowField'
import
{
fetchUploadWorkflowFile
}
from
'@/api/common'
import
type
{
Answer
,
Attachment
}
from
'@/types/chat'
interface
CardWorkflowFormProps
{
attachment
:
Attachment
answer
:
Answer
onSubmitWorkflow
:
(
paramMap
:
Record
<
string
,
string
>
,
recordId
?:
string
,
workFlowSessionId
?:
string
)
=>
void
onSubmitWorkflow
:
(
paramMap
:
WorkflowParamMap
,
recordId
?:
string
,
workFlowSessionId
?:
string
)
=>
void
isSubmitting
?:
boolean
isCompleted
?:
boolean
}
...
...
@@ -19,170 +33,98 @@ export const CardWorkflowForm: React.FC<CardWorkflowFormProps> = ({
isSubmitting
=
false
,
isCompleted
=
false
,
})
=>
{
const
[
formValues
,
setFormValues
]
=
useState
<
Record
<
string
,
string
|
string
[]
>>
({})
const
[
formValues
,
setFormValues
]
=
useState
<
WorkflowFormValues
>
({})
const
[
fileSelections
,
setFileSelections
]
=
useState
<
Record
<
string
,
WorkflowFileSelection
|
undefined
>>
({})
const
[
uploadingFieldKey
,
setUploadingFieldKey
]
=
useState
<
string
|
null
>
(
null
)
const
[
submitAttempted
,
setSubmitAttempted
]
=
useState
(
false
)
const
[
searchParams
]
=
useSearchParams
()
const
hasAutoSubmittedRef
=
useRef
(
false
)
// 防止重复自动提交
const
lastFirstQaFlagRef
=
useRef
<
boolean
|
undefined
>
(
undefined
)
// 记录上次的 firstQaFlag 值
const
onSubmitWorkflowRef
=
useRef
(
onSubmitWorkflow
)
// 保存 onSubmitWorkflow 的引用,避免依赖项变化导致重复执行
const
hasAutoSubmittedRef
=
useRef
(
false
)
const
lastFirstQaFlagRef
=
useRef
<
boolean
|
undefined
>
(
undefined
)
const
onSubmitWorkflowRef
=
useRef
(
onSubmitWorkflow
)
// 更新 onSubmitWorkflowRef 的引用
useEffect
(()
=>
{
onSubmitWorkflowRef
.
current
=
onSubmitWorkflow
},
[
onSubmitWorkflow
])
// 获取 workflow 字段(支持 text/select 等),使用 useMemo 稳定引用
const
workflowFields
=
useMemo
<
WorkflowFieldItem
[]
>
(()
=>
{
return
(
attachment
.
content
?.
fieldList
||
[]).
filter
(
field
=>
Boolean
(
field
?.
key
)
&&
Boolean
(
field
?.
type
))
},
[
attachment
.
content
?.
fieldList
]
)
const
workflowFields
=
useMemo
(
()
=>
parseWorkflowFormFields
(
attachment
),
[
attachment
.
content
?.
fieldList
],
)
const
isFieldValueFilled
=
(
field
:
WorkflowFieldItem
,
value
:
string
|
string
[]
|
undefined
)
=>
{
if
(
Array
.
isArray
(
value
))
{
return
value
.
length
>
0
}
return
!!
value
&&
value
.
trim
()
!==
''
}
const
missingRequiredFields
=
useMemo
(
()
=>
getWorkflowRequiredMissingFields
(
workflowFields
,
formValues
,
fileSelections
),
[
workflowFields
,
formValues
,
fileSelections
],
)
const
buildParamValue
=
(
value
:
string
|
string
[]
|
undefined
)
=>
{
if
(
Array
.
isArray
(
value
))
{
return
value
.
join
(
','
)
}
return
value
||
''
}
const
hasRequiredMissing
=
missingRequiredFields
.
length
>
0
// 初始化表单值,如果 firstQaFlag 为 true,从 URL query 中读取值
useEffect
(()
=>
{
const
initialValues
:
Record
<
string
,
string
|
string
[]
>
=
{}
if
(
answer
.
firstQaFlag
===
true
)
{
// 从 URL query 参数中读取值,匹配 fieldList 中的 key
workflowFields
.
forEach
((
field
)
=>
{
const
queryValue
=
searchParams
.
get
(
field
.
key
)
if
(
field
.
type
===
'select'
&&
field
.
multiple
)
{
initialValues
[
field
.
key
]
=
queryValue
?
queryValue
.
split
(
','
).
map
(
item
=>
item
.
trim
()).
filter
(
Boolean
)
:
[]
}
else
{
initialValues
[
field
.
key
]
=
queryValue
||
''
}
})
}
else
{
// 正常初始化,所有字段为空
workflowFields
.
forEach
((
field
)
=>
{
initialValues
[
field
.
key
]
=
field
.
type
===
'select'
&&
field
.
multiple
?
[]
:
''
})
}
setFormValues
(
initialValues
)
setFormValues
(
createInitialWorkflowFormValues
(
workflowFields
,
{
searchParams
,
useQueryPreset
:
answer
.
firstQaFlag
===
true
,
}))
setFileSelections
(
createInitialWorkflowFileSelections
(
workflowFields
))
setSubmitAttempted
(
false
)
},
[
workflowFields
,
searchParams
,
answer
.
firstQaFlag
])
// 单独处理 firstQaFlag 的变化,重置自动提交标志
useEffect
(()
=>
{
// 重置自动提交标志,当 firstQaFlag 从 true 变为 false/undefined 时重新评估
if
(
answer
.
firstQaFlag
!==
true
)
{
hasAutoSubmittedRef
.
current
=
false
lastFirstQaFlagRef
.
current
=
false
// 重置记录的值,确保下次变为 true 时能正确触发
lastFirstQaFlagRef
.
current
=
false
}
else
{
// 如果 firstQaFlag 为 true,初始化 lastFirstQaFlagRef(如果还没有设置)
// 这样可以处理组件首次渲染时 firstQaFlag 已经是 true 的情况
if
(
lastFirstQaFlagRef
.
current
===
undefined
)
{
lastFirstQaFlagRef
.
current
=
false
// 设置为 false,让自动提交逻辑能正确触发
}
else
if
(
lastFirstQaFlagRef
.
current
===
undefined
)
{
lastFirstQaFlagRef
.
current
=
false
}
},
[
answer
.
firstQaFlag
])
// 如果 firstQaFlag 为 true,自动提交(仅对流式返回的新记录,历史记录不会渲染此组件)
const
uploadWorkflowFile
=
async
(
file
:
File
):
Promise
<
string
>
=>
{
const
res
=
await
fetchUploadWorkflowFile
(
file
)
const
url
=
extractUploadUrlFromResponse
(
res
?.
data
)
if
(
!
url
)
throw
new
Error
(
'上传成功但未返回文件地址'
)
return
url
}
useEffect
(()
=>
{
// 如果 firstQaFlag 为 true,尝试自动提交
// 检查 firstQaFlag 是否从 false/undefined 变为 true(首次变为 true)
const
isFirstQaFlagChangedToTrue
=
answer
.
firstQaFlag
===
true
&&
lastFirstQaFlagRef
.
current
!==
true
// 只在 firstQaFlag 首次变为 true 且尚未自动提交时执行
if
(
isFirstQaFlagChangedToTrue
&&
!
hasAutoSubmittedRef
.
current
&&
!
isSubmitting
&&
!
isCompleted
)
{
// 检查是否有字段(workflowFields 不为空)
if
(
workflowFields
.
length
===
0
)
{
console
.
log
(
'[CardWorkflowForm] 自动提交:workflowFields 为空,跳过'
)
// 更新记录的值
lastFirstQaFlagRef
.
current
=
answer
.
firstQaFlag
return
}
// 检查是否所有必填字段都有值(从 URL query 中读取的)
// 只校验 required === true 的字段
const
requiredFields
=
workflowFields
.
filter
(
field
=>
field
.
required
===
true
)
const
allRequiredFieldsHaveValues
=
requiredFields
.
length
===
0
||
requiredFields
.
every
((
field
)
=>
{
const
queryValue
=
searchParams
.
get
(
field
.
key
)
if
(
field
.
type
===
'select'
&&
field
.
multiple
)
{
return
!!
queryValue
&&
queryValue
.
split
(
','
).
map
(
item
=>
item
.
trim
()).
filter
(
Boolean
).
length
>
0
}
return
!!
queryValue
&&
queryValue
.
trim
()
!==
''
})
console
.
log
(
'[CardWorkflowForm] 自动提交检查:'
,
{
firstQaFlag
:
answer
.
firstQaFlag
,
hasAutoSubmitted
:
hasAutoSubmittedRef
.
current
,
isSubmitting
,
isCompleted
,
workflowFieldsCount
:
workflowFields
.
length
,
requiredFieldsCount
:
requiredFields
.
length
,
allRequiredFieldsHaveValues
,
requiredFields
:
requiredFields
.
map
(
f
=>
({
key
:
f
.
key
,
label
:
f
.
label
})),
workflowFields
:
workflowFields
.
map
(
f
=>
({
key
:
f
.
key
,
label
:
f
.
label
,
required
:
f
.
required
,
type
:
f
.
type
})),
})
const
requiredFields
=
workflowFields
.
filter
(
field
=>
field
.
required
)
const
allRequiredFieldsHaveValues
=
requiredFields
.
every
(
field
=>
isWorkflowFieldFilled
(
field
,
formValues
[
field
.
key
],
fileSelections
[
field
.
key
]),
)
if
(
allRequiredFieldsHaveValues
)
{
// 立即设置 hasAutoSubmittedRef,防止重复提交
hasAutoSubmittedRef
.
current
=
true
// 更新记录的值
lastFirstQaFlagRef
.
current
=
answer
.
firstQaFlag
// 构建 paramMap
const
paramMap
:
Record
<
string
,
string
>
=
{}
workflowFields
.
forEach
((
field
)
=>
{
const
queryValue
=
searchParams
.
get
(
field
.
key
)
if
(
field
.
type
===
'select'
&&
field
.
multiple
)
{
const
values
=
queryValue
?
queryValue
.
split
(
','
).
map
(
item
=>
item
.
trim
()).
filter
(
Boolean
)
:
[]
paramMap
[
field
.
key
]
=
values
.
join
(
','
)
}
else
{
paramMap
[
field
.
key
]
=
queryValue
||
''
}
})
// 获取 recordId 和 workFlowSessionId(从 answer 中获取,第一次可能没有)
// 注意:这里使用最新的 answer 值,即使依赖项中没有包含它们
const
recordId
=
answer
.
recordId
const
workFlowSessionId
=
answer
.
workFlowSessionId
console
.
log
(
'[CardWorkflowForm] 自动提交:准备提交'
,
{
paramMap
,
recordId
,
workFlowSessionId
})
// 延迟一下再提交,确保表单值已经设置完成
const
timeoutId
=
setTimeout
(()
=>
{
console
.
log
(
'[CardWorkflowForm] 自动提交:执行提交'
)
onSubmitWorkflowRef
.
current
(
paramMap
,
recordId
,
workFlowSessionId
)
},
100
)
// 清理函数:如果组件卸载或依赖项变化,取消定时器
return
()
=>
{
clearTimeout
(
timeoutId
)
const
runAutoSubmit
=
async
()
=>
{
const
paramMap
=
await
buildWorkflowParamMapAsync
(
workflowFields
,
formValues
,
fileSelections
,
uploadWorkflowFile
,
)
onSubmitWorkflowRef
.
current
(
paramMap
,
answer
.
recordId
,
answer
.
workFlowSessionId
)
}
const
timeoutId
=
window
.
setTimeout
(()
=>
{
void
runAutoSubmit
()
},
100
)
return
()
=>
window
.
clearTimeout
(
timeoutId
)
}
else
{
console
.
log
(
'[CardWorkflowForm] 自动提交:必填字段值不完整,跳过'
,
{
requiredFields
:
requiredFields
.
map
(
f
=>
({
key
:
f
.
key
,
label
:
f
.
label
,
value
:
searchParams
.
get
(
f
.
key
)
})),
allFields
:
workflowFields
.
map
(
f
=>
({
key
:
f
.
key
,
label
:
f
.
label
,
required
:
f
.
required
,
value
:
searchParams
.
get
(
f
.
key
),
type
:
f
.
type
})),
})
// 更新记录的值
lastFirstQaFlagRef
.
current
=
answer
.
firstQaFlag
}
lastFirstQaFlagRef
.
current
=
answer
.
firstQaFlag
}
else
{
// 更新记录的值(即使不执行自动提交,也要更新记录)
else
{
lastFirstQaFlagRef
.
current
=
answer
.
firstQaFlag
}
// 移除 onSubmitWorkflow 和 textFields 作为依赖项,使用 useRef 保存引用,避免函数引用变化或 textFields 更新导致重复触发
// textFields 更新不应该触发重新提交,因为我们已经检查了 firstQaFlag 的变化
},
[
answer
.
firstQaFlag
,
isSubmitting
,
isCompleted
,
searchParams
])
},
[
answer
.
firstQaFlag
,
isSubmitting
,
isCompleted
,
searchParams
,
workflowFields
,
formValues
,
fileSelections
,
answer
.
recordId
,
answer
.
workFlowSessionId
,
answer
.
conversationId
])
const
handleFieldChange
=
(
key
:
string
,
value
:
string
|
string
[])
=>
{
setFormValues
(
prev
=>
({
...
...
@@ -191,49 +133,79 @@ export const CardWorkflowForm: React.FC<CardWorkflowFormProps> = ({
}))
}
const
handleSubmit
=
()
=>
{
// 构建 paramMap
const
paramMap
:
Record
<
string
,
string
>
=
{}
workflowFields
.
forEach
((
field
)
=>
{
paramMap
[
field
.
key
]
=
buildParamValue
(
formValues
[
field
.
key
])
})
// 获取 recordId 和 workFlowSessionId(从 answer 中获取,第一次可能没有)
const
recordId
=
answer
.
recordId
const
workFlowSessionId
=
answer
.
workFlowSessionId
onSubmitWorkflow
(
paramMap
,
recordId
,
workFlowSessionId
)
const
handlePickFile
=
async
(
key
:
string
,
file
:
File
)
=>
{
setUploadingFieldKey
(
key
)
try
{
const
url
=
await
uploadWorkflowFile
(
file
)
setFileSelections
(
prev
=>
({
...
prev
,
[
key
]:
{
fileName
:
file
.
name
,
file
,
fileUrls
:
[
url
]
},
}))
setFormValues
(
prev
=>
({
...
prev
,
[
key
]:
file
.
name
,
}))
}
catch
(
error
)
{
console
.
error
(
'[CardWorkflowForm] 文件上传失败:'
,
error
)
}
finally
{
setUploadingFieldKey
(
null
)
}
}
const
hasRequiredMissing
=
workflowFields
.
some
(
field
=>
field
.
required
&&
!
isFieldValueFilled
(
field
,
formValues
[
field
.
key
]))
const
handleSubmit
=
async
()
=>
{
setSubmitAttempted
(
true
)
if
(
hasRequiredMissing
)
return
const
paramMap
=
await
buildWorkflowParamMapAsync
(
workflowFields
,
formValues
,
fileSelections
,
uploadWorkflowFile
,
)
onSubmitWorkflow
(
paramMap
,
answer
.
recordId
,
answer
.
workFlowSessionId
)
}
// 检查是否有可渲染字段
if
(
workflowFields
.
length
===
0
)
{
if
(
workflowFields
.
length
===
0
)
return
null
}
// 如果 firstQaFlag 为 true 且已经自动提交,隐藏表单
if
(
answer
.
firstQaFlag
===
true
&&
hasAutoSubmittedRef
.
current
)
{
if
(
answer
.
firstQaFlag
===
true
&&
hasAutoSubmittedRef
.
current
)
return
null
}
const
fieldDisabled
=
isSubmitting
||
isCompleted
||
(
answer
.
firstQaFlag
===
true
&&
hasAutoSubmittedRef
.
current
)
const
cardTitle
=
attachment
.
name
?.
trim
()
return
(
<
div
className=
"card-workflow-form"
>
{
cardTitle
&&
(
<
div
className=
"text-[15px] font-medium text-[#1f2937] mb-[12px]"
>
{
cardTitle
}
</
div
>
)
}
<
div
className=
"flex flex-col gap-[12px]"
>
{
workflowFields
.
map
(
field
=>
(
<
div
key=
{
field
.
key
}
>
<
CardWorkflowField
field=
{
field
}
value=
{
formValues
[
field
.
key
]
??
(
field
.
type
===
'select'
&&
field
.
multiple
?
[]
:
''
)
}
onChange=
{
handleFieldChange
}
disabled=
{
isSubmitting
||
isCompleted
||
(
answer
.
firstQaFlag
===
true
&&
hasAutoSubmittedRef
.
current
)
}
/>
</
div
>
))
}
{
workflowFields
.
map
((
field
)
=>
{
const
showRequiredError
=
submitAttempted
&&
field
.
required
&&
!
isWorkflowFieldFilled
(
field
,
formValues
[
field
.
key
],
fileSelections
[
field
.
key
])
return
(
<
div
key=
{
field
.
key
}
>
<
CardWorkflowField
field=
{
field
}
value=
{
formValues
[
field
.
key
]
??
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
SELECT
&&
field
.
multiple
?
[]
:
''
)
}
onChange=
{
handleFieldChange
}
onPickFile=
{
handlePickFile
}
disabled=
{
fieldDisabled
||
uploadingFieldKey
===
field
.
key
}
isUploading=
{
uploadingFieldKey
===
field
.
key
}
showRequiredError=
{
showRequiredError
}
/>
</
div
>
)
})
}
<
Button
color=
"primary"
onPress=
{
handleSubmit
}
isDisabled=
{
isSubmitting
||
isCompleted
||
hasRequiredMissing
||
(
answer
.
firstQaFlag
===
true
&&
hasAutoSubmittedRef
.
current
)
}
onPress=
{
()
=>
void
handleSubmit
()
}
isDisabled=
{
fieldDisabled
||
uploadingFieldKey
!=
null
||
(
submitAttempted
&&
hasRequiredMissing
)
||
(
answer
.
firstQaFlag
===
true
&&
hasAutoSubmittedRef
.
current
)
}
isLoading=
{
isSubmitting
}
className=
"mt-[8px]"
>
...
...
src/pages/Chat/components/ChatItem/ChatAnswerAttchment.tsx
View file @
c877b1a4
...
...
@@ -2,6 +2,7 @@
import
React
,
{
useState
}
from
'react'
import
{
Button
,
Link
}
from
'@heroui/react'
import
{
motion
}
from
'framer-motion'
import
type
{
WorkflowParamMap
}
from
'../../lib/workflowCardForm'
import
{
CardWorkflowForm
}
from
'./CardWorkflowForm'
import
type
{
Answer
,
Attachment
}
from
'@/types/chat'
import
AnswerProDetailIcon
from
'@/assets/svg/answerProDetail.svg?react'
...
...
@@ -17,7 +18,7 @@ interface ChatAnswerAttachmentProps {
answer
:
Answer
isLastAnswer
?:
boolean
onSubmitQuestion
?:
(
question
:
string
,
productCode
?:
string
,
workFlowSessionId
?:
string
,
recordId
?:
string
)
=>
void
onWorkflowSubmit
?:
(
paramMap
:
Record
<
string
,
string
>
,
recordId
?:
string
,
workFlowSessionId
?:
string
)
=>
void
onWorkflowSubmit
?:
(
paramMap
:
WorkflowParamMap
,
recordId
?:
string
,
workFlowSessionId
?:
string
)
=>
void
isWorkflowSubmitting
?:
boolean
isAsking
?:
boolean
}
...
...
@@ -181,7 +182,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({
answer=
{
answer
}
onSubmitWorkflow=
{
onWorkflowSubmit
||
(()
=>
{
})
}
isSubmitting=
{
isWorkflowSubmitting
}
isCompleted=
{
fals
e
}
isCompleted=
{
answer
.
workflowFormSubmitted
===
tru
e
}
/>
)
}
{
/* 其他 card-* 类型 */
}
...
...
src/pages/Chat/components/ChatItem/ChatAnswerBox.tsx
View file @
c877b1a4
import
{
Avatar
,
Button
}
from
'@heroui/react'
import
{
useEffect
,
useState
}
from
'react'
import
type
{
WorkflowParamMap
}
from
'../../lib/workflowCardForm'
import
{
ChatAnswerShower
}
from
'./ChatAnswerShower'
import
{
ChatAnswerParser
}
from
'./ChatAnswerParser'
import
{
ChatAnswerRecommend
}
from
'./ChatAnswerRecommend'
...
...
@@ -17,9 +18,9 @@ interface ChatAnswerBoxProps {
showIndex
:
number
isLastAnswer
:
boolean
index
:
number
onSubmitQuestion
:
(
question
:
string
,
productCode
?:
string
,
toolId
?:
string
,
extraParams
?:
{
paramMap
?:
Record
<
string
,
string
>
,
recordId
?:
string
,
recordType
?:
string
,
workFlowSessionId
?:
string
,
isReask
?:
boolean
,
busiType
?:
string
},
workFlowSessionId
?:
string
,
recordId
?:
string
)
=>
void
onSubmitQuestion
:
(
question
:
string
,
productCode
?:
string
,
toolId
?:
string
,
extraParams
?:
{
paramMap
?:
WorkflowParamMap
,
recordId
?:
string
,
recordType
?:
string
,
workFlowSessionId
?:
string
,
isReask
?:
boolean
,
busiType
?:
string
},
workFlowSessionId
?:
string
,
recordId
?:
string
)
=>
void
onRecommendLoadingChange
?:
(
loading
:
boolean
)
=>
void
onWorkflowSubmit
?:
(
paramMap
:
Record
<
string
,
string
>
,
recordId
?:
string
)
=>
void
onWorkflowSubmit
?:
(
paramMap
:
WorkflowParamMap
,
recordId
?:
string
)
=>
void
isWorkflowSubmitting
?:
boolean
allItems
?:
ChatRecord
[]
// 传入整个会话历史,用于查找最后一个 workFlowSessionId
}
...
...
src/pages/Chat/components/ChatItem/ChatAnswerParser.tsx
View file @
c877b1a4
import
React
,
{
useEffect
,
useState
}
from
'react'
import
{
Chip
}
from
'@heroui/react'
import
{
resolveDisplayAnswerText
}
from
'../../helper'
import
type
{
WorkflowParamMap
}
from
'../../lib/workflowCardForm'
import
{
ChatAnswerAttachment
}
from
'./ChatAnswerAttchment'
import
{
ChatAnswerOperate
}
from
'./ChatAnswerOperate'
import
{
formatMarkdown
}
from
'./markdownFormatter'
...
...
@@ -17,7 +19,7 @@ interface ChatAnswerParserProps {
onSubmitQuestion
:
(
question
:
string
,
productCode
?:
string
,
workFlowSessionId
?:
string
,
recordId
?:
string
)
=>
void
/** 是否在当前回答中隐藏点赞/点踩/复制按钮(例如策略页自动分析、重新分析) */
hideOperate
?:
boolean
onWorkflowSubmit
?:
(
paramMap
:
Record
<
string
,
string
>
,
recordId
?:
string
)
=>
void
onWorkflowSubmit
?:
(
paramMap
:
WorkflowParamMap
,
recordId
?:
string
)
=>
void
isWorkflowSubmitting
?:
boolean
isAsking
?:
boolean
}
...
...
@@ -41,7 +43,9 @@ function CheckIcon({ ...props }) {
}
export
const
ChatAnswerParser
:
React
.
FC
<
ChatAnswerParserProps
>
=
({
isLastAnswer
,
onTyping
,
onComplate
,
answer
,
isStopTyping
,
onSubmitQuestion
,
hideOperate
,
onWorkflowSubmit
,
isWorkflowSubmitting
,
isAsking
=
false
})
=>
{
const
formatAnswer
=
formatMarkdown
(
answer
.
answer
||
''
)
const
displayAnswerText
=
resolveDisplayAnswerText
(
answer
.
answer
||
''
)
const
formatAnswer
=
formatMarkdown
(
displayAnswerText
)
const
hasCards
=
Array
.
isArray
(
answer
.
cardList
)
&&
answer
.
cardList
.
length
>
0
const
[
displayedText
,
setDisplayedText
]
=
useState
(
''
)
const
[
currentIndex
,
setCurrentIndex
]
=
useState
(
0
)
const
[
isTyping
,
setIsTyping
]
=
useState
(
false
)
...
...
@@ -122,6 +126,13 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
if
(
isStopTyping
)
{
return
}
// 仅有 cardList、无正文时跳过打字机,流式过程中即可展示卡片
if
(
!
formatAnswer
.
length
&&
hasCards
)
{
setIsTyping
(
false
)
if
(
answer
.
endAnswerFlag
)
onComplate
()
return
}
if
(
!
isTyping
)
{
onTyping
()
setIsTyping
(
true
)
...
...
@@ -202,10 +213,7 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
</
MarkdownDetail
>
</
div
>
)
}
{
!
isTyping
&&
answer
.
cardList
&&
answer
.
cardList
?.
length
!==
0
&&
(
{
hasCards
&&
(
<
ChatAnswerAttachment
isLastAnswer=
{
isLastAnswer
}
onSubmitQuestion=
{
onSubmitQuestion
}
...
...
src/pages/Chat/components/ChatItem/ChatAnswerShower.tsx
View file @
c877b1a4
import
React
from
'react'
import
{
hasWorkflowFormCards
,
resolveDisplayAnswerText
}
from
'../../helper'
import
type
{
WorkflowParamMap
}
from
'../../lib/workflowCardForm'
import
{
formatMarkdown
}
from
'./markdownFormatter'
import
{
ChatAnswerAttachment
}
from
'./ChatAnswerAttchment'
import
{
ChatAnswerOperate
}
from
'./ChatAnswerOperate'
...
...
@@ -10,7 +12,7 @@ interface ChatAnswerShowerProps {
isLastAnswer
:
boolean
onSubmitQuestion
:
(
question
:
string
,
productCode
?:
string
,
workFlowSessionId
?:
string
,
recordId
?:
string
)
=>
void
hideOperate
?:
boolean
onWorkflowSubmit
?:
(
paramMap
:
Record
<
string
,
string
>
,
recordId
?:
string
)
=>
void
onWorkflowSubmit
?:
(
paramMap
:
WorkflowParamMap
,
recordId
?:
string
)
=>
void
isWorkflowSubmitting
?:
boolean
isAsking
?:
boolean
}
...
...
@@ -18,19 +20,27 @@ interface ChatAnswerShowerProps {
export
const
ChatAnswerShower
:
React
.
FC
<
ChatAnswerShowerProps
>
=
({
answer
,
isLastAnswer
,
onSubmitQuestion
,
hideOperate
:
hideOperateProp
,
onWorkflowSubmit
,
isWorkflowSubmitting
,
isAsking
=
false
})
=>
{
const
hideOperateByCard
=
(
answer
.
cardList
||
[]).
some
(
attachment
=>
attachment
.
type
===
'box'
||
attachment
?.
type
?.
includes
(
'card-'
))
const
hideOperate
=
hideOperateByCard
||
hideOperateProp
const
displayAnswerText
=
resolveDisplayAnswerText
(
answer
.
answer
||
''
)
const
hasCards
=
Array
.
isArray
(
answer
.
cardList
)
&&
answer
.
cardList
.
length
>
0
// 历史首包静默记录隐藏卡片;流式/含工作流表单卡片需正常展示
const
shouldRenderCards
=
hasCards
&&
!
(
answer
.
isShow
===
true
&&
answer
.
firstQaFlag
===
true
&&
!
hasWorkflowFormCards
(
answer
.
cardList
)
)
return
(
<
div
className=
"answerShower"
>
{
answer
.
answer
&&
(
{
displayAnswerText
&&
(
<
div
className=
{
answer
.
cardList
?.
length
?
'mb-[12px] sm:mb-[20px]'
:
''
}
>
<
MarkdownDetail
>
{
formatMarkdown
(
answer
.
answer
||
''
)
}
{
formatMarkdown
(
displayAnswerText
)
}
</
MarkdownDetail
>
</
div
>
)
}
{
/* 历史记录(isShow === true)且 firstQaFlag === true 时,不渲染 ChatAnswerAttachment(避免触发自动提交)
流式返回的新记录(isShow === false)正常渲染 */
}
{
answer
.
cardList
&&
answer
.
cardList
?.
length
!==
0
&&
!
(
answer
.
isShow
===
true
&&
answer
.
firstQaFlag
===
true
)
&&
(
{
shouldRenderCards
&&
(
<
ChatAnswerAttachment
onSubmitQuestion=
{
onSubmitQuestion
}
isLastAnswer=
{
isLastAnswer
}
...
...
src/pages/Chat/components/ChatItem/ChatItemUser.tsx
View file @
c877b1a4
...
...
@@ -7,12 +7,16 @@ interface ChatItemUserProps {
}
export
const
ChatItemUser
:
React
.
FC
<
ChatItemUserProps
>
=
({
record
})
=>
{
const
question
=
record
.
question
?.
trim
()
if
(
!
question
)
return
null
return
(
<
div
className=
"chatItemUser"
>
<
div
className=
"text-[#b9b9bb] text-[13px] text-center w-full"
>
{
record
.
qaTime
}
</
div
>
<
div
className=
"flex justify-end"
>
<
div
className=
"sm:block w-[65px] flex-shrink-0"
></
div
>
<
div
className=
"text-[15px] bg-[#BFE9FE] rounded-[20px] box-border text-[#000000] px-[16px] py-[12px] sm:px-[24px] sm:py-[20px]"
>
{
record
.
question
}
</
div
>
<
div
className=
"text-[15px] bg-[#BFE9FE] rounded-[20px] box-border text-[#000000] px-[16px] py-[12px] sm:px-[24px] sm:py-[20px]"
>
{
question
}
</
div
>
<
Avatar
className=
"hidden sm:block ml-[20px] flex-shrink-0"
src=
{
AvatarUser
}
/>
</
div
>
<
div
className=
"h-[20px] sm:h-[32px] w-full"
></
div
>
...
...
src/pages/Chat/helper.ts
View file @
c877b1a4
import
type
{
Answer
,
ChatRecord
,
OriginalRecord
}
from
'@/types/chat'
import
{
isWorkflowFormAttachment
}
from
'./lib/workflowCardForm'
import
type
{
Answer
,
Attachment
,
ChatRecord
,
OriginalRecord
}
from
'@/types/chat'
export
interface
WorkflowControlPayload
{
recordType
?:
string
question
?:
unknown
end
?:
boolean
/** 为 false / "false" 时表示仅作路由:用新 recordType+question 静默续问,不在气泡展示本条 answer */
showFlag
?:
string
|
boolean
|
number
|
null
}
export
interface
WorkflowParseOptions
{
/** 当前流式分片的 answer 字段,用于 L4 兜底解析 */
answerChunk
?:
string
/** 当前包元数据携带 end:true 时补全解析结果中的 end */
packetEnd
?:
boolean
}
/** end 字段兼容 boolean true 与字符串 "true" */
export
function
isWorkflowControlEnd
(
end
:
unknown
):
boolean
{
if
(
end
===
true
)
return
true
if
(
typeof
end
===
'string'
&&
end
.
trim
().
toLowerCase
()
===
'true'
)
return
true
return
false
}
function
isShowFlagHidden
(
showFlag
:
WorkflowControlPayload
[
'showFlag'
]):
boolean
{
return
showFlag
===
false
||
showFlag
===
0
||
String
(
showFlag
??
''
).
trim
().
toLowerCase
()
===
'false'
}
/** answer 内 JSON 且 showFlag 为 false:静默续问,不展示本条回答 */
export
function
isHiddenWorkflowRedirectPayload
(
parsed
:
WorkflowControlPayload
|
null
|
undefined
):
boolean
{
if
(
!
parsed
?.
recordType
||
String
(
parsed
.
recordType
).
trim
()
===
''
)
return
false
if
(
parsed
.
question
==
null
||
String
(
parsed
.
question
).
trim
()
===
''
)
return
false
return
isShowFlagHidden
(
parsed
.
showFlag
)
}
export
function
isHiddenWorkflowRedirectControl
(
text
:
string
,
options
?:
WorkflowParseOptions
):
boolean
{
return
isHiddenWorkflowRedirectPayload
(
tryParseWorkflowControlJson
(
text
,
options
))
}
function
unescapeJsonStringValue
(
value
:
string
):
string
{
return
value
.
replace
(
/
\\
"/g
,
'"'
).
replace
(
/
\\\\
/g
,
'
\
\'
)
}
/** L2:去外层引号并还原常见转义 */
function normalizeEscaped(text: string): string {
let normalized = text.trim()
if (
(normalized.startsWith('
"') && normalized.endsWith('"
'))
|| (normalized.startsWith('
\
''
)
&&
normalized
.
endsWith
(
'
\'
'
))
)
{
normalized
=
normalized
.
slice
(
1
,
-
1
)
}
return
unescapeJsonStringValue
(
normalized
)
}
function
splitTopLevelJsonObjects
(
text
:
string
):
string
[]
{
const
results
:
string
[]
=
[]
let
depth
=
0
let
start
=
-
1
for
(
let
i
=
0
;
i
<
text
.
length
;
i
++
)
{
const
ch
=
text
[
i
]
if
(
ch
===
'{'
)
{
if
(
depth
===
0
)
start
=
i
depth
++
}
else
if
(
ch
===
'}'
)
{
depth
--
if
(
depth
===
0
&&
start
>=
0
)
{
results
.
push
(
text
.
slice
(
start
,
i
+
1
))
start
=
-
1
}
}
}
return
results
}
function
collectParseCandidates
(
text
:
string
):
string
[]
{
const
trimmed
=
text
.
trim
()
if
(
!
trimmed
)
return
[]
const
candidates
:
string
[]
=
[
trimmed
]
const
fenceStart
=
trimmed
.
indexOf
(
'```'
)
if
(
fenceStart
>=
0
)
{
const
lineEnd
=
trimmed
.
indexOf
(
'
\
n'
,
fenceStart
)
const
fenceEnd
=
trimmed
.
indexOf
(
'```'
,
lineEnd
>=
0
?
lineEnd
+
1
:
fenceStart
+
3
)
if
(
lineEnd
>=
0
&&
fenceEnd
>
lineEnd
)
candidates
.
push
(
trimmed
.
slice
(
lineEnd
+
1
,
fenceEnd
).
trim
())
}
const
brace
=
trimmed
.
match
(
/
\{[\s\S]
*
\}
/
)
if
(
brace
?.[
0
])
candidates
.
push
(
brace
[
0
].
trim
())
for
(
const
segment
of
splitTopLevelJsonObjects
(
trimmed
))
candidates
.
push
(
segment
.
trim
())
const
withNormalized
:
string
[]
=
[]
for
(
const
candidate
of
candidates
)
{
withNormalized
.
push
(
candidate
)
const
normalized
=
normalizeEscaped
(
candidate
)
if
(
normalized
!==
candidate
)
withNormalized
.
push
(
normalized
)
}
return
[...
new
Set
(
withNormalized
.
filter
(
Boolean
))]
}
function
normalizeWorkflowPayload
(
raw
:
Record
<
string
,
unknown
>
):
WorkflowControlPayload
|
null
{
const
recordType
=
raw
.
recordType
!=
null
?
String
(
raw
.
recordType
).
trim
()
:
''
const
hasQuestion
=
raw
.
question
!=
null
&&
String
(
raw
.
question
).
trim
()
!==
''
const
hasEnd
=
raw
.
end
!==
undefined
&&
raw
.
end
!==
null
const
hasShowFlag
=
raw
.
showFlag
!==
undefined
&&
raw
.
showFlag
!==
null
if
(
!
recordType
&&
!
hasQuestion
&&
!
hasEnd
&&
!
hasShowFlag
)
return
null
return
{
...(
recordType
?
{
recordType
}
:
{}),
...(
raw
.
question
!==
undefined
?
{
question
:
raw
.
question
}
:
{}),
...(
hasEnd
?
{
end
:
isWorkflowControlEnd
(
raw
.
end
)
}
:
{}),
...(
hasShowFlag
?
{
showFlag
:
raw
.
showFlag
as
WorkflowControlPayload
[
'showFlag'
]
}
:
{}),
}
}
/** L1:JSON.parse,若结果仍为 string 再 parse 一次 */
function
parseJsonLevel1
(
text
:
string
):
WorkflowControlPayload
|
null
{
try
{
let
parsed
:
unknown
=
JSON
.
parse
(
text
)
if
(
typeof
parsed
===
'string'
)
{
const
inner
=
parsed
.
trim
()
if
(
!
inner
)
return
null
parsed
=
JSON
.
parse
(
inner
)
}
if
(
parsed
&&
typeof
parsed
===
'object'
&&
!
Array
.
isArray
(
parsed
))
return
normalizeWorkflowPayload
(
parsed
as
Record
<
string
,
unknown
>
)
}
catch
{
// fall through
}
return
null
}
/** L3:正则提取 recordType / question / end / showFlag */
function
extractByRegex
(
text
:
string
):
WorkflowControlPayload
|
null
{
const
recordTypeMatch
=
text
.
match
(
/"recordType"
\s
*:
\s
*"
((?:\\
.|
[^
"
\\])
*
)
"/
)
const
questionMatch
=
text
.
match
(
/"question"
\s
*:
\s
*"
((?:\\
.|
[^
"
\\])
*
)
"/
)
const
endMatch
=
text
.
match
(
/"end"
\s
*:
\s
*
(
true|false|"true"|"false"
)
/i
)
const
showFlagMatch
=
text
.
match
(
/"showFlag"
\s
*:
\s
*
(
false|true|"false"|"true"|0|1
)
/i
)
if
(
!
recordTypeMatch
&&
!
questionMatch
&&
!
endMatch
)
return
null
const
payload
:
WorkflowControlPayload
=
{}
if
(
recordTypeMatch
?.[
1
])
payload
.
recordType
=
unescapeJsonStringValue
(
recordTypeMatch
[
1
]).
trim
()
if
(
questionMatch
?.[
1
])
payload
.
question
=
unescapeJsonStringValue
(
questionMatch
[
1
])
if
(
endMatch
?.[
1
])
payload
.
end
=
isWorkflowControlEnd
(
endMatch
[
1
].
replace
(
/"/g
,
''
))
if
(
showFlagMatch
?.[
1
])
{
const
flag
=
showFlagMatch
[
1
].
replace
(
/"/g
,
''
).
toLowerCase
()
payload
.
showFlag
=
flag
===
'false'
||
flag
===
'0'
?
false
:
flag
}
return
payload
}
function
mergePacketEnd
(
payload
:
WorkflowControlPayload
|
null
,
options
?:
WorkflowParseOptions
,
):
WorkflowControlPayload
|
null
{
if
(
!
payload
)
return
null
if
(
options
?.
packetEnd
&&
payload
.
end
!==
true
)
return
{
...
payload
,
end
:
true
}
return
payload
}
function
parseWorkflowControlFromText
(
text
:
string
):
WorkflowControlPayload
|
null
{
const
candidates
=
collectParseCandidates
(
text
)
for
(
let
i
=
candidates
.
length
-
1
;
i
>=
0
;
i
--
)
{
const
candidate
=
candidates
[
i
]
const
level1
=
parseJsonLevel1
(
candidate
)
if
(
level1
)
return
level1
const
level2
=
parseJsonLevel1
(
normalizeEscaped
(
candidate
))
if
(
level2
)
return
level2
const
level3
=
extractByRegex
(
candidate
)
if
(
level3
)
return
level3
}
return
null
}
/** 流式分包元数据是否携带工作流结束标识 */
export
function
isWorkflowPacketEnd
(
data
:
Record
<
string
,
unknown
>
|
null
|
undefined
):
boolean
{
if
(
!
data
)
return
false
if
(
isWorkflowControlEnd
(
data
.
end
))
return
true
if
(
data
.
endAnswer
===
true
)
return
true
if
(
data
.
endAnswerFlag
===
true
)
return
true
return
false
}
/**
* 四层解析主/子工作流控制 JSON:
* L1 JSON.parse(含双层字符串)
* L2 normalizeEscaped 后再 parse
* L3 extractByRegex 字段兜底
* L4 对当前包 answerChunk 重复上述步骤,并用 packetEnd 补全 end
*/
export
function
tryParseWorkflowControlJson
(
text
:
string
,
options
?:
WorkflowParseOptions
):
WorkflowControlPayload
|
null
{
const
sources
=
[
text
]
if
(
options
?.
answerChunk
?.
trim
())
sources
.
push
(
options
.
answerChunk
)
for
(
const
source
of
sources
)
{
const
parsed
=
parseWorkflowControlFromText
(
source
)
const
merged
=
mergePacketEnd
(
parsed
,
options
)
if
(
merged
)
return
merged
}
return
null
}
/** 是否包含工作流表单卡片(card-workflow + form_input,且含 text/select/file 字段) */
export
function
hasWorkflowFormCards
(
cardList
?:
Attachment
[]):
boolean
{
return
Array
.
isArray
(
cardList
)
&&
cardList
.
some
(
isWorkflowFormAttachment
)
}
/** 是否为「进入子工作流」类控制 JSON(解析后含 recordType + question,且非 showFlag 隐藏路由) */
export
function
isEnterSubWorkflowControlAnswer
(
text
:
string
,
options
?:
WorkflowParseOptions
):
boolean
{
const
parsed
=
tryParseWorkflowControlJson
(
text
,
options
)
if
(
isHiddenWorkflowRedirectPayload
(
parsed
))
return
false
return
Boolean
(
parsed
?.
recordType
!=
null
&&
String
(
parsed
.
recordType
).
trim
()
&&
parsed
.
question
!=
null
,
)
}
/** 工作流控制 JSON 不在气泡中展示正文,仅保留卡片等内容 */
export
function
resolveDisplayAnswerText
(
text
:
string
,
options
?:
WorkflowParseOptions
):
string
{
if
(
!
text
?.
trim
())
return
text
||
''
if
(
isHiddenWorkflowRedirectControl
(
text
,
options
)
||
isEnterSubWorkflowControlAnswer
(
text
,
options
))
return
''
return
text
}
/** 将指定 AI 记录标记为工作流表单已提交 */
export
function
markWorkflowFormSubmittedOnRecord
(
items
:
ChatRecord
[],
sourceKey
:
string
):
ChatRecord
[]
{
return
items
.
map
((
item
)
=>
{
if
(
item
.
role
!==
'ai'
||
!
item
.
answerList
?.[
0
])
return
item
const
answer
=
item
.
answerList
[
0
]
const
key
=
answer
.
recordId
||
item
.
groupId
if
(
key
&&
String
(
key
)
===
String
(
sourceKey
))
{
return
{
...
item
,
answerList
:
[{
...
answer
,
workflowFormSubmitted
:
true
}],
}
}
return
item
})
}
export
function
findLastAiRecord
(
items
:
ChatRecord
[]):
{
record
:
ChatRecord
,
answer
:
Answer
}
|
null
{
for
(
let
i
=
items
.
length
-
1
;
i
>=
0
;
i
--
)
{
const
item
=
items
[
i
]
if
(
item
.
role
===
'ai'
&&
item
.
answerList
?.[
0
])
return
{
record
:
item
,
answer
:
item
.
answerList
[
0
]
}
}
return
null
}
/**
* 从 query_user_qa_record_list 返回列表的「最后一条」中提取 workflow 上下文。
* 优先在最后一条记录的 answerList 中从后往前找到同时带有 recordType 与 workFlowSessionId 的答案。
*/
/** 拉 QA 后最后一条是否已具备 recordType + workFlowSessionId */
export
function
hasQaWorkflowContext
(
qaRecords
:
OriginalRecord
[]
|
null
|
undefined
):
boolean
{
const
ctx
=
getLastQaRecordWorkflowContext
(
qaRecords
)
return
Boolean
(
ctx
.
recordType
&&
ctx
.
workFlowSessionId
)
}
export
function
getLastQaRecordWorkflowContext
(
qaRecords
:
OriginalRecord
[]
|
null
|
undefined
):
{
recordType
?:
string
workFlowSessionId
?:
string
...
...
@@ -37,22 +332,37 @@ export function processApiResponse(data: OriginalRecord[]): ChatRecord[] {
if
(
data
.
length
===
0
)
return
chatRecord
data
.
forEach
((
record
)
=>
{
let
questionText
=
record
.
question
?.
trim
()
if
(
!
questionText
&&
Array
.
isArray
(
record
.
answerList
))
{
for
(
let
i
=
record
.
answerList
.
length
-
1
;
i
>=
0
;
i
--
)
{
const
a
=
record
.
answerList
[
i
]
as
Answer
const
fromAnswer
=
a
?.
question
?.
trim
()
||
a
?.
extra
?.
optimizedQuestion
?.
trim
()
if
(
fromAnswer
)
{
questionText
=
fromAnswer
break
}
}
}
// 如果没有 question 字段(后端未返回问题文案),则不生成用户问题气泡,避免展示“空气炮”
if
(
record
.
question
)
{
if
(
questionText
)
{
chatRecord
.
push
({
role
:
'user'
,
...
record
,
question
:
questionText
,
})
}
if
(
record
.
answerList
&&
record
.
answerList
.
length
>
0
)
{
record
.
answerList
.
forEach
((
answer
)
=>
{
answer
.
isShow
=
true
if
(
answer
.
answer
)
answer
.
answer
=
resolveDisplayAnswerText
(
answer
.
answer
)
})
chatRecord
.
push
({
role
:
'ai'
,
answerList
:
record
.
answerList
,
question
:
record
.
question
,
question
:
questionText
,
toolId
:
record
.
toolId
,
})
}
...
...
src/pages/Chat/lib/workflowCardForm.ts
0 → 100644
View file @
c877b1a4
import
type
{
Attachment
}
from
'@/types/chat'
/** 工作流 form_input 支持的字段类型 */
export
const
WORKFLOW_FIELD_TYPE
=
{
TEXT
:
'text'
,
SELECT
:
'select'
,
FILE
:
'file'
,
}
as
const
export
type
WorkflowFieldType
=
typeof
WORKFLOW_FIELD_TYPE
[
keyof
typeof
WORKFLOW_FIELD_TYPE
]
|
string
/** 提交提问接口 paramMap:key 为 fieldList[].key;text/select 为 string,file 为 URL 数组 */
export
type
WorkflowParamMap
=
Record
<
string
,
string
|
string
[]
>
export
interface
WorkflowFieldOption
{
id
?:
string
text
?:
string
type
?:
string
}
/** 归一化后的表单字段(来自 cardList[].content.fieldList) */
export
interface
WorkflowFormField
{
key
:
string
label
:
string
type
:
WorkflowFieldType
required
:
boolean
multiple
?:
boolean
options
?:
WorkflowFieldOption
[]
/** 接口预填(如已上传文档 URL) */
presetValue
?:
string
}
export
type
WorkflowFormValues
=
Record
<
string
,
string
|
string
[]
>
export
interface
WorkflowFileSelection
{
fileName
:
string
file
?:
File
/** 上传接口返回的文件访问 URL,写入 paramMap[field.key] */
fileUrls
:
string
[]
}
export
function
isSupportedWorkflowFieldType
(
type
:
string
|
undefined
):
boolean
{
const
normalized
=
String
(
type
??
''
).
trim
().
toLowerCase
()
return
normalized
===
WORKFLOW_FIELD_TYPE
.
TEXT
||
normalized
===
WORKFLOW_FIELD_TYPE
.
SELECT
||
normalized
===
WORKFLOW_FIELD_TYPE
.
FILE
}
function
isHttpUrl
(
value
:
string
):
boolean
{
return
/^https
?
:
\/\/
/i
.
test
(
value
.
trim
())
}
/** 从 card-workflow 附件解析可渲染字段(保持 fieldList 顺序) */
export
function
parseWorkflowFormFields
(
attachment
:
Attachment
):
WorkflowFormField
[]
{
const
rawList
=
attachment
.
content
?.
fieldList
if
(
!
Array
.
isArray
(
rawList
))
return
[]
return
rawList
.
filter
(
field
=>
Boolean
(
field
?.
key
)
&&
Boolean
(
field
?.
type
)
&&
isSupportedWorkflowFieldType
(
field
.
type
))
.
map
(
field
=>
({
key
:
String
(
field
.
key
).
trim
(),
label
:
String
(
field
.
label
??
field
.
key
).
trim
(),
type
:
String
(
field
.
type
).
trim
().
toLowerCase
(),
required
:
field
.
required
===
true
,
multiple
:
field
.
multiple
===
true
,
options
:
field
.
options
,
presetValue
:
field
.
value
!=
null
?
String
(
field
.
value
).
trim
()
:
undefined
,
}))
}
export
function
isWorkflowFormAttachment
(
attachment
:
Attachment
):
boolean
{
return
attachment
?.
type
===
'card-workflow'
&&
attachment
?.
content
?.
name
===
'form_input'
&&
parseWorkflowFormFields
(
attachment
).
length
>
0
}
export
function
createInitialWorkflowFormValues
(
fields
:
WorkflowFormField
[],
options
?:
{
searchParams
?:
URLSearchParams
,
useQueryPreset
?:
boolean
},
):
WorkflowFormValues
{
const
initial
:
WorkflowFormValues
=
{}
const
searchParams
=
options
?.
searchParams
const
useQuery
=
options
?.
useQueryPreset
===
true
&&
searchParams
fields
.
forEach
((
field
)
=>
{
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
SELECT
&&
field
.
multiple
)
{
if
(
useQuery
)
{
const
queryValue
=
searchParams
!
.
get
(
field
.
key
)
initial
[
field
.
key
]
=
queryValue
?
queryValue
.
split
(
','
).
map
(
item
=>
item
.
trim
()).
filter
(
Boolean
)
:
[]
}
else
{
initial
[
field
.
key
]
=
[]
}
return
}
if
(
useQuery
)
{
const
queryValue
=
searchParams
!
.
get
(
field
.
key
)
initial
[
field
.
key
]
=
queryValue
??
field
.
presetValue
??
''
return
}
initial
[
field
.
key
]
=
field
.
presetValue
??
''
})
return
initial
}
export
function
createInitialWorkflowFileSelections
(
fields
:
WorkflowFormField
[],
):
Record
<
string
,
WorkflowFileSelection
|
undefined
>
{
const
selections
:
Record
<
string
,
WorkflowFileSelection
|
undefined
>
=
{}
fields
.
forEach
((
field
)
=>
{
if
(
field
.
type
!==
WORKFLOW_FIELD_TYPE
.
FILE
)
return
const
preset
=
field
.
presetValue
if
(
preset
&&
isHttpUrl
(
preset
))
{
selections
[
field
.
key
]
=
{
fileName
:
preset
.
split
(
'/'
).
pop
()
||
preset
,
fileUrls
:
[
preset
],
}
}
})
return
selections
}
export
function
isWorkflowFieldFilled
(
field
:
WorkflowFormField
,
value
:
string
|
string
[]
|
undefined
,
fileSelection
?:
WorkflowFileSelection
,
):
boolean
{
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
FILE
)
{
if
((
fileSelection
?.
fileUrls
?.
length
??
0
)
>
0
)
return
true
if
(
field
.
presetValue
&&
isHttpUrl
(
field
.
presetValue
))
return
true
return
false
}
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
SELECT
&&
field
.
multiple
)
{
return
Array
.
isArray
(
value
)
&&
value
.
length
>
0
}
if
(
typeof
value
===
'string'
)
return
value
.
trim
()
!==
''
return
false
}
export
function
getWorkflowRequiredMissingFields
(
fields
:
WorkflowFormField
[],
values
:
WorkflowFormValues
,
fileSelections
:
Record
<
string
,
WorkflowFileSelection
|
undefined
>
,
):
WorkflowFormField
[]
{
return
fields
.
filter
(
field
=>
field
.
required
&&
!
isWorkflowFieldFilled
(
field
,
values
[
field
.
key
],
fileSelections
[
field
.
key
]),
)
}
function
buildTextOrSelectParamValue
(
field
:
WorkflowFormField
,
value
:
string
|
string
[]
|
undefined
):
string
{
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
SELECT
&&
field
.
multiple
)
{
return
Array
.
isArray
(
value
)
?
value
.
join
(
','
)
:
''
}
return
typeof
value
===
'string'
?
value
:
''
}
function
buildFileParamValue
(
field
:
WorkflowFormField
,
fileSelection
?:
WorkflowFileSelection
,
):
string
[]
{
const
urls
=
fileSelection
?.
fileUrls
?.
filter
(
url
=>
url
.
trim
())
??
[]
if
(
urls
.
length
>
0
)
return
urls
if
(
field
.
presetValue
&&
isHttpUrl
(
field
.
presetValue
))
return
[
field
.
presetValue
]
return
[]
}
/**
* 按 cardList.fieldList 的 key 构建 paramMap(与 submit_question_stream 约定一致)
* - text / select → string(未填为 "")
* - file → string[](未填为 [],已上传为 ["http://..."])
*/
export
function
buildWorkflowParamMap
(
fields
:
WorkflowFormField
[],
values
:
WorkflowFormValues
,
fileSelections
:
Record
<
string
,
WorkflowFileSelection
|
undefined
>
,
):
WorkflowParamMap
{
const
paramMap
:
WorkflowParamMap
=
{}
fields
.
forEach
((
field
)
=>
{
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
FILE
)
paramMap
[
field
.
key
]
=
buildFileParamValue
(
field
,
fileSelections
[
field
.
key
])
else
paramMap
[
field
.
key
]
=
buildTextOrSelectParamValue
(
field
,
values
[
field
.
key
])
})
return
paramMap
}
/** 解析上传接口返回中的文件 URL */
export
function
extractUploadUrlFromResponse
(
data
:
unknown
):
string
{
if
(
typeof
data
===
'string'
&&
data
.
trim
())
return
data
.
trim
()
if
(
Array
.
isArray
(
data
))
{
const
first
=
data
.
find
(
item
=>
typeof
item
===
'string'
&&
item
.
trim
())
return
typeof
first
===
'string'
?
first
.
trim
()
:
''
}
if
(
data
&&
typeof
data
===
'object'
)
{
const
record
=
data
as
Record
<
string
,
unknown
>
for
(
const
key
of
[
'url'
,
'fileUrl'
,
'docUrl'
,
'path'
,
'filePath'
])
{
const
candidate
=
record
[
key
]
if
(
typeof
candidate
===
'string'
&&
candidate
.
trim
())
return
candidate
.
trim
()
}
if
(
Array
.
isArray
(
record
.
urlList
))
{
return
extractUploadUrlFromResponse
(
record
.
urlList
)
}
}
return
''
}
/**
* 构建 paramMap;若 file 已选本地文件但未上传,会先调用 uploadFile 再写入 URL 数组
*/
export
async
function
buildWorkflowParamMapAsync
(
fields
:
WorkflowFormField
[],
values
:
WorkflowFormValues
,
fileSelections
:
Record
<
string
,
WorkflowFileSelection
|
undefined
>
,
uploadFile
?:
(
file
:
File
)
=>
Promise
<
string
>
,
):
Promise
<
WorkflowParamMap
>
{
const
nextSelections
=
{
...
fileSelections
}
for
(
const
field
of
fields
)
{
if
(
field
.
type
!==
WORKFLOW_FIELD_TYPE
.
FILE
||
!
uploadFile
)
continue
const
selection
=
nextSelections
[
field
.
key
]
if
(
!
selection
?.
file
||
(
selection
.
fileUrls
?.
length
??
0
)
>
0
)
continue
const
url
=
await
uploadFile
(
selection
.
file
)
if
(
url
)
{
nextSelections
[
field
.
key
]
=
{
...
selection
,
fileUrls
:
[
url
],
}
}
}
return
buildWorkflowParamMap
(
fields
,
values
,
nextSelections
)
}
export
function
getWorkflowFieldValidationMessage
(
field
:
WorkflowFormField
):
string
{
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
FILE
)
return
`请上传
${
field
.
label
.
replace
(
/(可选)|
\(
可选
\)
/g
,
''
).
trim
()
||
'文件'
}
`
if
(
field
.
type
===
WORKFLOW_FIELD_TYPE
.
SELECT
)
return
`请选择
${
field
.
label
}
`
return
`请填写
${
field
.
label
}
`
}
src/store/conversationSlice.ts
View file @
c877b1a4
...
...
@@ -137,7 +137,7 @@ const conversationSlice = createSlice({
state
.
businessInsightWorkflow
.
isInSubWorkflow
=
true
state
.
businessInsightWorkflow
.
paramMap
=
{}
},
updateBusinessInsightParamMap
:
(
state
,
action
:
PayloadAction
<
Record
<
string
,
string
>>
)
=>
{
updateBusinessInsightParamMap
:
(
state
,
action
:
PayloadAction
<
Record
<
string
,
string
|
string
[]
>>
)
=>
{
state
.
businessInsightWorkflow
.
paramMap
=
{
...
state
.
businessInsightWorkflow
.
paramMap
,
...
action
.
payload
,
...
...
src/types/chat.ts
View file @
c877b1a4
...
...
@@ -91,6 +91,8 @@ export interface Answer {
qaTime
?:
number
|
string
firstQaFlag
?:
boolean
workFlowSessionId
?:
string
/** 工作流表单已提交(paramMap 已发往流式接口) */
workflowFormSubmitted
?:
boolean
}
export
interface
OriginalRecord
{
...
...
src/types/conversation.ts
View file @
c877b1a4
...
...
@@ -20,7 +20,7 @@ export interface ConversationState {
mainWorkflowSessionId
:
string
|
null
subWorkflowSessionId
:
string
|
null
subWorkflowRecordType
:
string
|
null
paramMap
:
Record
<
string
,
string
>
paramMap
:
Record
<
string
,
string
|
string
[]
>
isInSubWorkflow
:
boolean
pendingQuestion
:
string
|
null
}
...
...
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