Commit c877b1a4 by Liu

fix:底部按钮基本逻辑

parent e879c818
...@@ -60,3 +60,12 @@ export function fetchDownloadFile(docId: string) { ...@@ -60,3 +60,12 @@ export function fetchDownloadFile(docId: string) {
responseType: 'blob', 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)
}
...@@ -12,10 +12,11 @@ import { clearBusinessInsightWorkflow, clearCurrentToolId, setCurrentToolId } fr ...@@ -12,10 +12,11 @@ import { clearBusinessInsightWorkflow, clearCurrentToolId, setCurrentToolId } fr
import { setIsAsking } from '@/store/chatSlice' import { setIsAsking } from '@/store/chatSlice'
import { fetchToolList } from '@/api/home' import { fetchToolList } from '@/api/home'
import { safeSessionStorageGetItem, safeSessionStorageRemoveItem, safeSessionStorageSetItem } from '@/lib/utils' 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 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 { fetchSessionConversationId, fetchUserQaRecordPage } from '@/api/conversation'
import { hasQaWorkflowContext } from '@/pages/Chat/helper'
interface ChatEditorProps { interface ChatEditorProps {
onChange?: (value: string) => void onChange?: (value: string) => void
...@@ -26,9 +27,11 @@ interface ChatEditorProps { ...@@ -26,9 +27,11 @@ interface ChatEditorProps {
showContentTips?: boolean showContentTips?: boolean
initialValue?: string initialValue?: string
hideTools?: boolean // 隐藏工具选择部分 hideTools?: boolean // 隐藏工具选择部分
/** 工作流表单展示且未提交时禁用底部输入 */
inputDisabled?: boolean
} }
// onToolClick // 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 dispatch = useAppDispatch()
const [content, setContent] = useState(initialValue) const [content, setContent] = useState(initialValue)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
...@@ -39,7 +42,6 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -39,7 +42,6 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
const isAsking = useAppSelector((state: RootState) => state.chat.isAsking) const isAsking = useAppSelector((state: RootState) => state.chat.isAsking)
const [toolList, setToolList] = useState<ToolListRow[]>([]) const [toolList, setToolList] = useState<ToolListRow[]>([])
const [selectedToolId, setSelectedToolId] = useState<string | null>(null) const [selectedToolId, setSelectedToolId] = useState<string | null>(null)
const [isToolBtnActive, setIsToolBtnActive] = useState<boolean>(true)
const currentToolId = useAppSelector((state: RootState) => state.conversation.currentToolId) const currentToolId = useAppSelector((state: RootState) => state.conversation.currentToolId)
const [showToolQuestion, setShowToolQuestion] = useState<boolean>(false) const [showToolQuestion, setShowToolQuestion] = useState<boolean>(false)
const [sessionToolId, setSessionToolId] = useState<string | null>(null) const [sessionToolId, setSessionToolId] = useState<string | null>(null)
...@@ -176,8 +178,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -176,8 +178,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
newSearchParams.delete('toolId') newSearchParams.delete('toolId')
setSearchParams(newSearchParams, { replace: true }) setSearchParams(newSearchParams, { replace: true })
} }
setSelectedToolId(null) setSelectedToolId(toolList.length > 0 ? normalizeToolId(toolList[0].toolId) : null)
setIsToolBtnActive(true)
return return
} }
...@@ -185,8 +186,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -185,8 +186,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
const newSearchParams = new URLSearchParams(searchParams) const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete('toolId') newSearchParams.delete('toolId')
setSearchParams(newSearchParams, { replace: true }) setSearchParams(newSearchParams, { replace: true })
setSelectedToolId(null) setSelectedToolId(toolList.length > 0 ? normalizeToolId(toolList[0].toolId) : null)
setIsToolBtnActive(true)
return return
} }
...@@ -195,48 +195,38 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -195,48 +195,38 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
if (currentToolId && hasPersistentToolSource) { if (currentToolId && hasPersistentToolSource) {
setSelectedToolId(currentToolId) setSelectedToolId(currentToolId)
setIsToolBtnActive(false)
return return
} }
if (sessionToolId) { if (sessionToolId) {
setSelectedToolId(sessionToolId) setSelectedToolId(sessionToolId)
setIsToolBtnActive(false)
return return
} }
// 如果 currentToolId 和 sessionToolId 都没有值,根据路由中的 toolId 来决定 // 如果 currentToolId 和 sessionToolId 都没有值,根据路由中的 toolId 来决定
if (toolIdFromUrl) { if (toolIdFromUrl) {
setSelectedToolId(toolIdFromUrl) setSelectedToolId(toolIdFromUrl)
setIsToolBtnActive(false)
} }
else { else {
setSelectedToolId(null) setSelectedToolId(toolList.length > 0 ? normalizeToolId(toolList[0].toolId) : null)
setIsToolBtnActive(true)
} }
}, [currentToolId, sessionToolId, toolIdFromUrl, searchParams, setSearchParams, fromCollect]) }, [currentToolId, sessionToolId, toolIdFromUrl, searchParams, setSearchParams, fromCollect, toolList])
// 监听 sessionStorage 中的 currentToolId(历史点击时写入)来辅助高亮逻辑 // 监听 sessionStorage 中的 currentToolId(历史点击时写入)来辅助高亮逻辑
useEffect(() => { useEffect(() => {
const syncSessionToolId = () => { const syncSessionToolId = () => {
const storedToolId = safeSessionStorageGetItem('currentToolId') const storedToolId = safeSessionStorageGetItem('currentToolId')
// 如果 currentToolId 是空字符串,视为 null,确保制度活化能正确高亮
setSessionToolId(storedToolId && storedToolId.trim() ? storedToolId : null) setSessionToolId(storedToolId && storedToolId.trim() ? storedToolId : null)
} }
syncSessionToolId() syncSessionToolId()
window.addEventListener('storage', syncSessionToolId) window.addEventListener('storage', syncSessionToolId)
// 监听强制重置为制度活化的事件(登录成功后触发)
const handleForceReset = () => { const handleForceReset = () => {
// 强制同步 sessionStorage(此时应该已经被清除了)
syncSessionToolId() syncSessionToolId()
// 确保 Redux 也被清除(如果还没有)
if (currentToolId) { if (currentToolId) {
dispatch(clearCurrentToolId()) dispatch(clearCurrentToolId())
} }
// 强制设置为制度活化 setSelectedToolId(toolList.length > 0 ? normalizeToolId(toolList[0].toolId) : null)
setSelectedToolId(null)
setIsToolBtnActive(true)
} }
window.addEventListener('forceResetToGeneralMode', handleForceReset) window.addEventListener('forceResetToGeneralMode', handleForceReset)
...@@ -244,12 +234,11 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -244,12 +234,11 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
window.removeEventListener('storage', syncSessionToolId) window.removeEventListener('storage', syncSessionToolId)
window.removeEventListener('forceResetToGeneralMode', handleForceReset) window.removeEventListener('forceResetToGeneralMode', handleForceReset)
} }
}, [currentToolId, dispatch]) }, [currentToolId, dispatch, toolList])
// 当路由变化时,同步更新 sessionToolId(因为 storage 事件不会在同标签页触发) // 当路由变化时,同步更新 sessionToolId(因为 storage 事件不会在同标签页触发)
useEffect(() => { useEffect(() => {
const storedToolId = safeSessionStorageGetItem('currentToolId') const storedToolId = safeSessionStorageGetItem('currentToolId')
// 如果 currentToolId 是空字符串,视为 null,确保制度活化能正确高亮
setSessionToolId(storedToolId && storedToolId.trim() ? storedToolId : null) setSessionToolId(storedToolId && storedToolId.trim() ? storedToolId : null)
// 当路由切换(如点击历史记录)时,同步最新的 sessionStorage,避免同标签页删除后状态不同步 // 当路由切换(如点击历史记录)时,同步最新的 sessionStorage,避免同标签页删除后状态不同步
}, [toolIdFromUrl, location.pathname]) }, [toolIdFromUrl, location.pathname])
...@@ -277,25 +266,19 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -277,25 +266,19 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
} }
} }
const editorLocked = isAsking || inputDisabled
const handleSubmit = () => { const handleSubmit = () => {
if (isAsking) if (editorLocked)
return return
// 以 DOM 为准,避免 content 状态与 contentEditable 不同步(如 IME、快速连点)导致误判为空 // 以 DOM 为准,避免 content 状态与 contentEditable 不同步(如 IME、快速连点)导致误判为空
const textFromDom = (editorRef.current?.textContent ?? '').trim() const textFromDom = (editorRef.current?.textContent ?? '').trim()
const text = textFromDom || content.trim() const text = textFromDom || content.trim()
if (checkAuth()) { if (checkAuth()) {
if (text) { if (text) {
// 只在提质增效模式下传递 toolId,制度活化不传 const defaultToolId = toolList.length > 0 ? normalizeToolId(toolList[0].toolId) : null
let toolId: string | undefined const activeToolId = selectedToolId ?? sessionToolId ?? toolIdFromUrl ?? defaultToolId
const toolId = activeToolId || undefined
if (selectedToolId) {
// 提质增效模式:使用选中的 toolId
toolId = selectedToolId
}
else {
// 制度活化:不传递 toolId
toolId = undefined
}
onSubmit?.(text, toolId) onSubmit?.(text, toolId)
setContent('') setContent('')
if (editorRef.current) { if (editorRef.current) {
...@@ -360,8 +343,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -360,8 +343,7 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
if (isGeneral) { if (isGeneral) {
dispatch(clearCurrentToolId()) dispatch(clearCurrentToolId())
setIsToolBtnActive(true) setSelectedToolId(normalizeToolId(tool.toolId))
setSelectedToolId(null)
safeSessionStorageRemoveItem('showToolQuestion') safeSessionStorageRemoveItem('showToolQuestion')
safeSessionStorageRemoveItem('currentToolId') safeSessionStorageRemoveItem('currentToolId')
setSessionToolId(null) setSessionToolId(null)
...@@ -384,7 +366,6 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -384,7 +366,6 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
} }
dispatch(setCurrentToolId(normalizedToolId)) dispatch(setCurrentToolId(normalizedToolId))
setSelectedToolId(normalizedToolId) setSelectedToolId(normalizedToolId)
setIsToolBtnActive(false)
safeSessionStorageSetItem('currentToolId', normalizedToolId) safeSessionStorageSetItem('currentToolId', normalizedToolId)
setSessionToolId(normalizedToolId) setSessionToolId(normalizedToolId)
} }
...@@ -436,30 +417,21 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -436,30 +417,21 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
}, },
})) }))
if (!isGeneral) { const qaList = Array.isArray(qaRes?.data) ? qaRes.data : []
const listForPair = toolList?.length if (!hasQaWorkflowContext(qaList)) {
? toolList const pair = getToolQuestionPairFromRow(tool)
: readToolListFromSessionStorage() if (pair.recordType) {
const clickedPair = getToolQuestionPairFromRow(tool) try {
if (clickedPair.toolId && clickedPair.recordType) sessionStorage.setItem('pendingWorkflowSilentBootstrap', JSON.stringify({
setWorkflowRequestSeedPair(clickedPair.toolId, clickedPair.recordType) conversationId,
const { toolId: wfToolId, recordType: wfRecordType } = getWorkflowToolIdAndRecordTypeForRequest(listForPair) toolId: isGeneral ? '' : normalizedToolId,
if (wfToolId && wfRecordType) { recordType: pair.recordType,
console.log('[ChatEditor] 工具会话 - 准备延迟触发 autoSubmitQuestion', { conversationId, toolId: wfToolId, recordType: wfRecordType }) busiType: '01',
requestAnimationFrame(() => { }))
requestAnimationFrame(() => { }
console.log('[ChatEditor] 工具会话 - 触发 autoSubmitQuestion 事件', { conversationId, toolId: wfToolId, recordType: wfRecordType }) catch {
window.dispatchEvent(new CustomEvent('autoSubmitQuestion', { // ignore
detail: { }
conversationId,
question: '',
toolId: wfToolId,
recordType: wfRecordType,
busiType: '01',
},
}))
})
})
} }
} }
sessionStorage.removeItem('toolHistoryLoading') sessionStorage.removeItem('toolHistoryLoading')
...@@ -551,15 +523,15 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -551,15 +523,15 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
> >
<div <div
ref={editorRef} ref={editorRef}
contentEditable
enterKeyHint="send" enterKeyHint="send"
role="textbox" role="textbox"
translate="no" translate="no"
className="w-full min-h-[40px] word-break-break-all max-h-[48px] p-2 rounded overflow-y-auto outline-none" className="w-full min-h-[40px] word-break-break-all max-h-[48px] p-2 rounded overflow-y-auto outline-none"
onInput={handleInput} onInput={handleInput}
onFocus={onFocus} onFocus={onFocus}
onKeyDown={handleKeyDown} onKeyDown={editorLocked ? undefined : handleKeyDown}
onPaste={handlePaste} onPaste={editorLocked ? undefined : handlePaste}
contentEditable={!editorLocked}
suppressContentEditableWarning suppressContentEditableWarning
data-placeholder={showToolQuestion ? '输入您想问的问题吧~' : '输入您想问的问题吧~'} data-placeholder={showToolQuestion ? '输入您想问的问题吧~' : '输入您想问的问题吧~'}
style={{ style={{
...@@ -567,11 +539,12 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -567,11 +539,12 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
maxHeight: '48px', maxHeight: '48px',
wordBreak: 'break-all', wordBreak: 'break-all',
...(!hideTools && toolList && toolList.length > 0 ? { marginBottom: '40px' } : {}), ...(!hideTools && toolList && toolList.length > 0 ? { marginBottom: '40px' } : {}),
...(editorLocked ? { pointerEvents: 'none' as const, opacity: 0.5 } : {}),
}} }}
> >
</div> </div>
<Tooltip isOpen={Boolean(token) && showContentTips && !isAsking && !content} color="foreground" content="请输入您的问题📖" placement="top-end"> <Tooltip isOpen={Boolean(token) && showContentTips && !editorLocked && !content} color="foreground" content="请输入您的问题📖" placement="top-end">
<Button className="ask-send" onPress={handleSubmit} radius="full" isDisabled={!content || isAsking} isIconOnly color="primary"> <Button className="ask-send" onPress={handleSubmit} radius="full" isDisabled={!content || editorLocked} isIconOnly color="primary">
<SendIcon /> <SendIcon />
</Button> </Button>
</Tooltip> </Tooltip>
...@@ -605,13 +578,10 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth, ...@@ -605,13 +578,10 @@ const ChatEditorBase: React.FC<ChatEditorProps & WithAuthProps> = ({ checkAuth,
{!hideTools && toolList && toolList.length > 0 && ( {!hideTools && toolList && toolList.length > 0 && (
<div className="absolute left-4 bottom-2 flex items-center gap-3 pointer-events-auto pl-[16px]"> <div className="absolute left-4 bottom-2 flex items-center gap-3 pointer-events-auto pl-[16px]">
{toolList.map((tool: ToolListRow, index: number) => { {toolList.map((tool: ToolListRow, index: number) => {
// 根据 selectedToolId 或路由中的 toolId 进行匹配,注意数据类型统一转换为字符串比较
// 优先使用 selectedToolId(用户点击后的状态),其次使用 sessionStorage 中的 currentToolId(刷新时使用),再次使用路由中的 toolId(初始化时使用)
const toolIdStr = normalizeToolId(tool.toolId) const toolIdStr = normalizeToolId(tool.toolId)
const isSelectedByState = Boolean(selectedToolId) && toolIdStr === String(selectedToolId) const defaultToolId = toolList.length > 0 ? normalizeToolId(toolList[0].toolId) : null
const isSelectedBySession = !selectedToolId && sessionToolId && toolIdStr === String(sessionToolId) const activeToolId = selectedToolId ?? sessionToolId ?? toolIdFromUrl ?? defaultToolId
const isGeneralMode = isGeneralInstitutionalToolRow(tool) && isToolBtnActive && !selectedToolId && !sessionToolId && !toolIdFromUrl const isSelected = activeToolId != null && toolIdStr === String(activeToolId)
const isSelected = isSelectedByState || isSelectedBySession || isGeneralMode
const baseBtnClass const baseBtnClass
= 'w-auto h-[32px] px-3 rounded-full shadow-none text-[12px] flex items-center gap-2 transition-all duration-200 border' = 'w-auto h-[32px] px-3 rounded-full shadow-none text-[12px] flex items-center gap-2 transition-all duration-200 border'
......
...@@ -5,7 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom' ...@@ -5,7 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { useClickAway, useSessionStorageState } from 'ahooks' import { useClickAway, useSessionStorageState } from 'ahooks'
import styles from './Navbar.module.less' import styles from './Navbar.module.less'
import { NavBarItem } from './components/NavBarItem' import { NavBarItem } from './components/NavBarItem'
import { clearNavigationFlag, createConversation } from '@/store/conversationSlice' import { clearBusinessInsightWorkflow, clearNavigationFlag, createConversation } from '@/store/conversationSlice'
import { createTacticsConversation } from '@/store/tacticsSlice' import { createTacticsConversation } from '@/store/tacticsSlice'
import type { WithAuthProps } from '@/auth/withAuth' import type { WithAuthProps } from '@/auth/withAuth'
import { withAuth } from '@/auth/withAuth' import { withAuth } from '@/auth/withAuth'
...@@ -15,6 +15,7 @@ import { isMobile } from '@/utils' ...@@ -15,6 +15,7 @@ import { isMobile } from '@/utils'
import { safeLocalStorageSetItem, waitForToken } from '@/lib/utils' import { safeLocalStorageSetItem, waitForToken } from '@/lib/utils'
import { fetchUserQaRecordPage } from '@/api/conversation' import { fetchUserQaRecordPage } from '@/api/conversation'
import { findToolRowBySessionToolId, getToolQuestionPairFromRow, readToolListFromSessionStorage, toolRowTriggersEmptyQuestionBootstrap } from '@/lib/toolRoleRecordTypes' import { findToolRowBySessionToolId, getToolQuestionPairFromRow, readToolListFromSessionStorage, toolRowTriggersEmptyQuestionBootstrap } from '@/lib/toolRoleRecordTypes'
import { hasQaWorkflowContext } from '@/pages/Chat/helper'
// import { User } from './components/User' // import { User } from './components/User'
interface NavbarProps { interface NavbarProps {
...@@ -32,6 +33,7 @@ const NavbarBase: React.FC<NavbarProps & WithAuthProps> = ({ isHistoryVisible, c ...@@ -32,6 +33,7 @@ const NavbarBase: React.FC<NavbarProps & WithAuthProps> = ({ isHistoryVisible, c
const handleCreateConversation = () => { const handleCreateConversation = () => {
const sessionToolId = sessionStorage.getItem('currentToolId') || undefined const sessionToolId = sessionStorage.getItem('currentToolId') || undefined
dispatch(clearBusinessInsightWorkflow())
dispatch(createConversation({ dispatch(createConversation({
conversationData: { conversationData: {
...(sessionToolId ? { toolId: sessionToolId } : {}), ...(sessionToolId ? { toolId: sessionToolId } : {}),
...@@ -150,14 +152,19 @@ const NavbarBase: React.FC<NavbarProps & WithAuthProps> = ({ isHistoryVisible, c ...@@ -150,14 +152,19 @@ const NavbarBase: React.FC<NavbarProps & WithAuthProps> = ({ isHistoryVisible, c
await waitForToken() await waitForToken()
const qaRes = await fetchUserQaRecordPage(currentConversationId, currentToolId) const qaRes = await fetchUserQaRecordPage(currentConversationId, currentToolId)
const qaRecords = qaRes?.data || []
window.dispatchEvent(new CustomEvent('toolHistoryLoaded', { window.dispatchEvent(new CustomEvent('toolHistoryLoaded', {
detail: { detail: {
conversationId: currentConversationId, conversationId: currentConversationId,
toolId: currentToolId, toolId: currentToolId,
qaRecords: qaRes?.data || [], qaRecords,
}, },
})) }))
// 最后一条已有 workflow 上下文则不再自动补问;否则静默首包一次
if (hasQaWorkflowContext(qaRecords))
return
const pair = getToolQuestionPairFromRow(row) const pair = getToolQuestionPairFromRow(row)
const recordTypeForAuto = pair.recordType || '01' const recordTypeForAuto = pair.recordType || '01'
requestAnimationFrame(() => { requestAnimationFrame(() => {
......
...@@ -49,11 +49,16 @@ export function findGeneralToolRow(toolList: unknown): ToolListRow | null { ...@@ -49,11 +49,16 @@ export function findGeneralToolRow(toolList: unknown): ToolListRow | null {
return hit ?? 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 { export function findToolRowBySessionToolId(toolList: unknown, sessionToolId: string | null | undefined): ToolListRow | null {
const list = asToolRows(toolList) const list = asToolRows(toolList)
const tid = normalizeToolId(sessionToolId) const tid = normalizeToolId(sessionToolId)
if (!tid) if (!tid)
return findGeneralToolRow(toolList) return getFirstToolListRow(toolList)
return list.find(r => normalizeToolId(r.toolId) === tid) ?? null return list.find(r => normalizeToolId(r.toolId) === tid) ?? null
} }
...@@ -71,6 +76,19 @@ export function getToolQuestionPairFromRow(row: ToolListRow | null | undefined): ...@@ -71,6 +76,19 @@ export function getToolQuestionPairFromRow(row: ToolListRow | null | undefined):
return { toolId, recordType } 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 */ /** 左侧常见问题 recordType */
export function getCommonQuestionRecordTypeForToolRow(row: ToolListRow | null | undefined): string { export function getCommonQuestionRecordTypeForToolRow(row: ToolListRow | null | undefined): string {
if (!row) if (!row)
......
import { getToolQuestionPairFromRow } from '@/lib/toolRoleRecordTypes' import { getToolQuestionPairFromRow, resolveSubmitQuestionToolPair } from '@/lib/toolRoleRecordTypes'
import type { ToolListRow } from '@/types/toolList' import type { ToolListRow } from '@/types/toolList'
/** /**
...@@ -104,16 +104,15 @@ export function getToolListFirstPair(toolList: unknown[]): { toolId: string, rec ...@@ -104,16 +104,15 @@ export function getToolListFirstPair(toolList: unknown[]): { toolId: string, rec
* 若已有流式返回缓存则优先;否则使用列表第 0 项的 userRole 首段(首次)。 * 若已有流式返回缓存则优先;否则使用列表第 0 项的 userRole 首段(首次)。
*/ */
export function getWorkflowToolIdAndRecordTypeForRequest(toolList: unknown[]): { toolId: string, recordType: string } { export function getWorkflowToolIdAndRecordTypeForRequest(toolList: unknown[]): { toolId: string, recordType: string } {
const last = getWorkflowLastToolPair() const sessionToolId = typeof window !== 'undefined' ? sessionStorage.getItem('currentToolId') : null
if (last) const fromList = resolveSubmitQuestionToolPair(sessionToolId, Array.isArray(toolList) ? (toolList as import('@/types/toolList').ToolListRow[]) : undefined)
return last if (fromList.recordType)
const seed = getWorkflowRequestSeedPair() return fromList
if (seed)
return seed
return getToolListFirstPair(toolList) return getToolListFirstPair(toolList)
} }
/** Chat 提交:流式最后一次优先,否则为本次点击写入的 seed(get_tool_list[0]) */ /** Chat 提交:与 resolveSubmitQuestionToolPair 一致(get_tool_list 当前项 userRole 第一段) */
export function getWorkflowToolIdAndRecordTypeForSubmit(): { toolId: string, recordType: string } | null { export function getWorkflowToolIdAndRecordTypeForSubmit(sessionToolId?: string | null): { toolId: string, recordType: string } {
return getWorkflowLastToolPair() ?? getWorkflowRequestSeedPair() const fromSession = typeof window !== 'undefined' ? sessionStorage.getItem('currentToolId') : null
return resolveSubmitQuestionToolPair(sessionToolId ?? fromSession ?? undefined)
} }
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom' import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { Button } from '@heroui/react' import { Button } from '@heroui/react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useScroll } from 'ahooks' import { useScroll } from 'ahooks'
import styles from './Chat.module.less' 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 { ChatWelcome } from './components/ChatWelcome'
import { ChatItemUser } from './components/ChatItem/ChatItemUser' import { ChatItemUser } from './components/ChatItem/ChatItemUser'
import { ChatAnswerBox } from './components/ChatItem/ChatAnswerBox' import { ChatAnswerBox } from './components/ChatItem/ChatAnswerBox'
import { trimSpacesOnly } from './components/ChatItem/markdownFormatter' import { trimSpacesOnly } from './components/ChatItem/markdownFormatter'
import { ChatEditor } from '@/components/ChatEditor' import { ChatEditor } from '@/components/ChatEditor'
import type { ChatRecord } from '@/types/chat' import type { Answer, ChatRecord, OriginalRecord } from '@/types/chat'
import { fetchSessionConversationId, fetchUserQaRecordPage } from '@/api/conversation' import { fetchSessionConversationId, fetchUserQaRecordPage } from '@/api/conversation'
import { fetchCheckTokenApi, fetchStreamResponse } from '@/api/chat' 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 { getUserRolesForApi, safeSessionStorageGetItem, waitForToken } from '@/lib/utils'
import { findGeneralToolRow, findToolRowBySessionToolId, readToolListFromSessionStorage } from '@/lib/toolRoleRecordTypes' import { findToolRowBySessionToolId, getFirstToolListRow, readToolListFromSessionStorage, resolveSubmitQuestionToolPair } from '@/lib/toolRoleRecordTypes'
import { getWorkflowToolIdAndRecordTypeForSubmit, setWorkflowLastToolPair } from '@/lib/workflowToolParams' import { setWorkflowLastToolPair } from '@/lib/workflowToolParams'
import type { RootState } from '@/store' import type { RootState } from '@/store'
import { useAppDispatch, useAppSelector } from '@/store/hook' import { useAppDispatch, useAppSelector } from '@/store/hook'
import ScrollBtoIcon from '@/assets/svg/scrollBto.svg?react' import ScrollBtoIcon from '@/assets/svg/scrollBto.svg?react'
import { setIsAsking } from '@/store/chatSlice' import { setIsAsking } from '@/store/chatSlice'
import SdreamLoading from '@/components/SdreamLoading' 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) { function makeLocalGroupId(prefix: string) {
try { try {
// modern browsers // modern browsers
...@@ -87,7 +96,7 @@ export const Chat: React.FC = () => { ...@@ -87,7 +96,7 @@ export const Chat: React.FC = () => {
mainWorkflowSessionId: string | null mainWorkflowSessionId: string | null
subWorkflowSessionId: string | null subWorkflowSessionId: string | null
subWorkflowRecordType: string | null subWorkflowRecordType: string | null
paramMap: Record<string, string> paramMap: WorkflowParamMap
isInSubWorkflow: boolean isInSubWorkflow: boolean
pendingQuestion: string | null pendingQuestion: string | null
}>({ }>({
...@@ -102,6 +111,43 @@ export const Chat: React.FC = () => { ...@@ -102,6 +111,43 @@ export const Chat: React.FC = () => {
const expectingSubWorkflowOpenRef = useRef(false) const expectingSubWorkflowOpenRef = useRef(false)
const pendingSubOpenRecordTypeRef = useRef<string | null>(null) const pendingSubOpenRecordTypeRef = useRef<string | null>(null)
const pendingSubQuestionRef = 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)时, // 当外部系统直接以 /chat/:id 链接进入(没有 location.state,且 URL 中也没有 toolId)时,
// 视为一次新的会话入口:重置为制度活化,清除历史遗留的工具模式状态 // 视为一次新的会话入口:重置为制度活化,清除历史遗留的工具模式状态
...@@ -193,6 +239,217 @@ export const Chat: React.FC = () => { ...@@ -193,6 +239,217 @@ export const Chat: React.FC = () => {
businessInsightWorkflowRef.current = businessInsightWorkflow businessInsightWorkflowRef.current = businessInsightWorkflow
}, [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的数据 */ /** 处理正常stream的数据 */
const handleStreamMesageData = (msg: any, question: string) => { const handleStreamMesageData = (msg: any, question: string) => {
setAllItems((prevItems) => { setAllItems((prevItems) => {
...@@ -261,28 +518,51 @@ export const Chat: React.FC = () => { ...@@ -261,28 +518,51 @@ export const Chat: React.FC = () => {
// 只移除空格和制表符,保留换行符(避免移除末尾的 \n\n) // 只移除空格和制表符,保留换行符(避免移除末尾的 \n\n)
filteredAnswer = trimSpacesOnly(filteredAnswer) 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,避免过早禁用输入框 // 在流式响应过程中,忽略服务器返回的 endAnswerFlag,避免过早禁用输入框
const { endAnswerFlag: _omitEndAnswerFlag, ...dataWithoutEndFlag } = msg.content.data || {} const { endAnswerFlag: _omitEndAnswerFlag, ...dataWithoutEndFlag } = packetData
const chunkToolId = dataWithoutEndFlag.toolId != null ? String(dataWithoutEndFlag.toolId).trim() : '' const chunkToolId = dataWithoutEndFlag.toolId != null ? String(dataWithoutEndFlag.toolId).trim() : ''
const chunkRecordType = dataWithoutEndFlag.recordType != null ? String(dataWithoutEndFlag.recordType).trim() : '' const chunkRecordType = dataWithoutEndFlag.recordType != null ? String(dataWithoutEndFlag.recordType).trim() : ''
if (chunkToolId && chunkRecordType) if (chunkToolId && chunkRecordType)
setWorkflowLastToolPair(chunkToolId, chunkRecordType) setWorkflowLastToolPair(chunkToolId, chunkRecordType)
const resolvedSessionId = resolveWorkFlowSessionId(
{ ...existingAnswer, ...dataWithoutEndFlag },
existingAnswer.workFlowSessionId,
)
// 保留之前保存的 workFlowSessionId 和 recordId,避免被覆盖 // 保留之前保存的 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] = {
...newItems[currentIndex], ...newItems[currentIndex],
question: isEmptyQuestion ? undefined : question, question: isEmptyQuestion ? undefined : question,
answerList: [ answerList: [mergedAnswer],
{ }
...existingAnswer, lastStreamedAiAnswerRef.current = {
...dataWithoutEndFlag, ...mergedAnswer,
// 如果新的数据中没有 workFlowSessionId,保留之前的值 workflowParseText: trimSpacesOnly(originalAnswer),
workFlowSessionId: dataWithoutEndFlag.workFlowSessionId ?? existingAnswer.workFlowSessionId, workflowAnswerChunk: answerChunk,
// 如果新的数据中没有 recordId,保留之前的值 workflowPacketEnd: packetEnd || lastStreamedAiAnswerRef.current?.workflowPacketEnd,
recordId: dataWithoutEndFlag.recordId ?? existingAnswer.recordId,
isShow: false,
answer: filteredAnswer,
},
],
} }
} }
return newItems return newItems
...@@ -326,28 +606,33 @@ export const Chat: React.FC = () => { ...@@ -326,28 +606,33 @@ export const Chat: React.FC = () => {
}) })
} }
const workflowBootstrapDoneRef = useRef<string | null>(null)
/** 提交问题 */ /** 提交问题 */
const handleSubmitQuestion = async ( const handleSubmitQuestion = async (
question: string, question: string,
productCode?: string, productCode?: string,
toolId?: 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, workFlowSessionId?: string,
recordId?: string, recordId?: string,
) => { ) => {
// 优先读取缓存中的 toolId,再回退到传参或 Redux // 优先读取缓存中的 toolId,再回退到传参或 Redux
const sessionToolId = sessionStorage.getItem('currentToolId') ?? undefined const sessionToolId = sessionStorage.getItem('currentToolId') ?? undefined
const resolvedToolId = toolId ?? sessionToolId ?? currentToolId ?? undefined const resolvedToolId = toolId ?? sessionToolId ?? currentToolId ?? undefined
// 业务洞察等工作流参数:有主/子会话态时合并进 extra(不依赖写死 toolId) const omitWorkFlowSessionId = extraParams?.omitWorkFlowSessionId === true
// 业务洞察等工作流参数:有主/子会话态时合并进 extra(新建会话首包静默提问不传 session)
let workflowExtraParams = extraParams let workflowExtraParams = extraParams
if (businessInsightWorkflowRef.current) { if (!omitWorkFlowSessionId && businessInsightWorkflowRef.current) {
const workflowState = businessInsightWorkflowRef.current const workflowState = businessInsightWorkflowRef.current
if (workflowState.isInSubWorkflow) { if (workflowState.isInSubWorkflow) {
workflowExtraParams = { workflowExtraParams = {
...extraParams, ...extraParams,
recordType: workflowState.subWorkflowRecordType || undefined, recordType: workflowState.subWorkflowRecordType || undefined,
workFlowSessionId: workflowState.subWorkflowSessionId || workFlowSessionId, workFlowSessionId: workflowState.subWorkflowSessionId || workFlowSessionId,
paramMap: workflowState.paramMap, paramMap: extraParams?.paramMap
? { ...workflowState.paramMap, ...extraParams.paramMap }
: workflowState.paramMap,
} }
} }
else if (workflowState.mainWorkflowSessionId) { else if (workflowState.mainWorkflowSessionId) {
...@@ -357,12 +642,30 @@ export const Chat: React.FC = () => { ...@@ -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)时走自动欢迎语流式逻辑; // 空问题 + 显式传入 recordType/busiType(如工具入口 autoSubmit)时走自动欢迎语流式逻辑;
// 已有主会话后的空问题(如「开子工作流」)不应走欢迎语分支 // 开子工作流、已带 session、或已有主会话的空问题不应走欢迎语分支
const isEmptyQuestion = !question.trim() const isEmptyQuestion = !question.trim()
const isAutoToolEmptySubmit = isEmptyQuestion const bootstrapBusiType = workflowExtraParams?.busiType ?? extraParams?.busiType ?? '01'
&& Boolean(extraParams?.recordType && extraParams?.busiType) const isNewSessionSilentBootstrap = isEmptyQuestion
&& omitWorkFlowSessionId
&& Boolean(workflowExtraParams?.recordType && bootstrapBusiType)
const isAutoToolEmptySubmit = isNewSessionSilentBootstrap || (
isEmptyQuestion
&& Boolean(workflowExtraParams?.recordType && bootstrapBusiType)
&& !businessInsightWorkflowRef.current.mainWorkflowSessionId && !businessInsightWorkflowRef.current.mainWorkflowSessionId
&& !expectingSubWorkflowOpenRef.current
&& !extraParams?.workFlowSessionId
&& !workflowExtraParams?.workFlowSessionId
)
isAutoSubmitQualityImprovementRef.current = isAutoToolEmptySubmit isAutoSubmitQualityImprovementRef.current = isAutoToolEmptySubmit
if (isAutoSubmitQualityImprovementRef.current) { if (isAutoSubmitQualityImprovementRef.current) {
// 重置欢迎语候选 // 重置欢迎语候选
...@@ -374,22 +677,50 @@ export const Chat: React.FC = () => { ...@@ -374,22 +677,50 @@ export const Chat: React.FC = () => {
abortControllerRef.current.abort() 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 const isNew = allItems.length <= 1
dispatch(setIsAsking(true)) dispatch(setIsAsking(true))
// 先展示用户气泡与 AI 占位,避免 check_token 失败时界面毫无反馈且流式接口未发出 // 工作流静默续跑:单独追加 AI 占位,避免覆盖上一条(含 JSON 控制指令)的回答
if (question.trim()) { 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 => [ setAllItems(prevItems => [
...prevItems, ...prevItems,
{ {
role: 'user', role: 'user',
groupId: makeLocalGroupId('user'), groupId: makeLocalGroupId('user'),
question, question: questionText,
} as ChatRecord, } as ChatRecord,
{ {
role: 'ai', role: 'ai',
groupId: makeLocalGroupId('ai'), groupId: makeLocalGroupId('ai'),
answerList: [{ answer: '' }], question: questionText,
answerList: [{ answer: '', isShow: false }],
} as ChatRecord, } as ChatRecord,
]) ])
} }
...@@ -419,19 +750,19 @@ export const Chat: React.FC = () => { ...@@ -419,19 +750,19 @@ export const Chat: React.FC = () => {
fetchUrl = proxy + fetchUrl fetchUrl = proxy + fetchUrl
// 根据 toolId 添加相应的参数 // 根据 toolId 添加相应的参数(paramMap 表单提交不传 question)
const requestBody: Record<string, any> = { const requestBody: Record<string, any> = {
question,
conversationId: currentIdRef.current, conversationId: currentIdRef.current,
stream: true, stream: true,
productCode, productCode,
toolId: resolvedToolId ?? '', toolId: resolvedToolId ?? '',
} }
if (!isParamMapFormSubmit)
requestBody.question = question
// 如果是 workflow 提交,添加 paramMap、recordId 和 workFlowSessionId // 如果是 workflow 表单提交,添加 paramMap、recordId 和 workFlowSessionId
if (workflowExtraParams?.paramMap) { if (isParamMapFormSubmit && workflowExtraParams?.paramMap)
requestBody.paramMap = workflowExtraParams.paramMap requestBody.paramMap = workflowExtraParams.paramMap
}
// 优先使用 workflowExtraParams 中的值,其次使用直接传递的参数 // 优先使用 workflowExtraParams 中的值,其次使用直接传递的参数
if (workflowExtraParams?.recordId) { if (workflowExtraParams?.recordId) {
requestBody.recordId = workflowExtraParams.recordId requestBody.recordId = workflowExtraParams.recordId
...@@ -439,27 +770,32 @@ export const Chat: React.FC = () => { ...@@ -439,27 +770,32 @@ export const Chat: React.FC = () => {
else if (recordId) { else if (recordId) {
requestBody.recordId = 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 isReask = workflowExtraParams?.isReask === true
const isAutoQualityImprovement = isAutoSubmitQualityImprovementRef.current const isAutoQualityImprovement = isAutoSubmitQualityImprovementRef.current
const explicitWorkFlowSessionId = workflowExtraParams?.workFlowSessionId || workFlowSessionId const explicitWorkFlowSessionId = omitWorkFlowSessionId
const shouldUseWorkflowSession = Boolean(resolvedToolId && String(resolvedToolId).trim() !== '') ? undefined
if (shouldUseWorkflowSession && !isReask) { : (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 let resolvedWorkflowSessionId: string | undefined
if (explicitWorkFlowSessionId) { if (explicitWorkFlowSessionId) {
resolvedWorkflowSessionId = explicitWorkFlowSessionId resolvedWorkflowSessionId = explicitWorkFlowSessionId
} }
else if (lastLoadedQaWorkflowContextRef.current?.workFlowSessionId) {
resolvedWorkflowSessionId = lastLoadedQaWorkflowContextRef.current.workFlowSessionId
}
else if (!isAutoQualityImprovement) { else if (!isAutoQualityImprovement) {
for (let i = allItems.length - 1; i >= 0; i--) { for (let i = allItems.length - 1; i >= 0; i--) {
const item = allItems[i] const item = allItems[i]
if (item.role === 'ai' && item.answerList?.[0]?.workFlowSessionId) { const fromHistory = item.role === 'ai' ? resolveWorkFlowSessionId(item.answerList?.[0]) : undefined
resolvedWorkflowSessionId = item.answerList[0].workFlowSessionId if (fromHistory) {
resolvedWorkflowSessionId = fromHistory
break break
} }
} }
...@@ -469,32 +805,17 @@ export const Chat: React.FC = () => { ...@@ -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) { if (workflowExtraParams?.recordType) {
requestBody.recordType = workflowExtraParams.recordType requestBody.recordType = workflowExtraParams.recordType
// 如果同时传入了 busiType,也使用传入的值 if (workflowExtraParams?.busiType)
if (workflowExtraParams?.busiType) {
requestBody.busiType = workflowExtraParams.busiType requestBody.busiType = workflowExtraParams.busiType
}
}
// 制度活化:toolId 为空字符串或 undefined
else if (!resolvedToolId || resolvedToolId === '') {
requestBody.toolId = ''
requestBody.busiType = '01'
requestBody.recordType = '01'
} }
else { else {
const workflowPair = getWorkflowToolIdAndRecordTypeForSubmit() requestBody.recordType = submitPairFromList.recordType || '01'
if (workflowPair?.toolId && workflowPair?.recordType) { requestBody.busiType = workflowExtraParams?.busiType ?? extraParams?.busiType ?? '01'
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'
}
} }
fetchStreamResponse( fetchStreamResponse(
...@@ -568,140 +889,104 @@ export const Chat: React.FC = () => { ...@@ -568,140 +889,104 @@ export const Chat: React.FC = () => {
isAutoSubmitQualityImprovementRef.current = false isAutoSubmitQualityImprovementRef.current = false
autoWelcomeCandidateRef.current = '' autoWelcomeCandidateRef.current = ''
hasAutoWelcomeCandidateRef.current = false hasAutoWelcomeCandidateRef.current = false
// 设置最后一条 AI 回答的 endAnswerFlag,用于控制打字效果结束 const streamSnapshotForWf = lastStreamedAiAnswerRef.current
let workflowLastAnswer: WorkflowStreamAnswer | null = null
setAllItems((prevItems) => { setAllItems((prevItems) => {
const newItems = [...prevItems] const newItems = [...prevItems]
// 从后往前找到最后一个 AI 项 const streamed = lastStreamedAiAnswerRef.current
for (let i = newItems.length - 1; i >= 0; i--) { for (let i = newItems.length - 1; i >= 0; i--) {
if (newItems[i].role === 'ai' && newItems[i].answerList?.[0]) { if (newItems[i].role === 'ai' && newItems[i].answerList?.[0]) {
const prevAnswer = newItems[i].answerList[0].answer const base = newItems[i].answerList[0]
newItems[i] = { const mergedCardList = streamed?.cardList ?? base.cardList
...newItems[i], const hasCards = Array.isArray(mergedCardList) && mergedCardList.length > 0
answerList: [ const rawParseText = streamed?.workflowParseText ?? streamed?.answer ?? base.answer ?? ''
{ const streamParseOptions = buildWorkflowParseOptions(streamed)
...newItems[i].answerList[0], 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, 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 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 return newItems
}) })
pendingWorkflowFormSourceKeyRef.current = null
dispatch(setIsAsking(false)) 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) { if (isNew) {
setTimeout(() => { setTimeout(() => {
dispatch(fetchConversations()) dispatch(fetchConversations())
}, 2000) }, 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(() => { .catch(() => {
// 流式请求出错时的处理 // 流式请求出错时的处理
...@@ -731,11 +1016,88 @@ export const Chat: React.FC = () => { ...@@ -731,11 +1016,88 @@ export const Chat: React.FC = () => {
return newItems return newItems
}) })
dispatch(setIsAsking(false)) dispatch(setIsAsking(false))
workflowSilentAppendAiRef.current = false
pendingWorkflowFormSourceKeyRef.current = null
lastStreamedAiAnswerRef.current = null
}) })
} }
/** 处理 workflow 表单提交 */ handleSubmitQuestionRef.current = handleSubmitQuestion
const handleWorkflowSubmit = useCallback(async (paramMap: Record<string, string>, recordId?: string, workFlowSessionId?: string) => {
/** 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' const recordType = businessInsightWorkflowRef.current.subWorkflowRecordType?.trim() || 'A01'
await handleSubmitQuestion('', undefined, undefined, { await handleSubmitQuestion('', undefined, undefined, {
paramMap, paramMap,
...@@ -743,8 +1105,23 @@ export const Chat: React.FC = () => { ...@@ -743,8 +1105,23 @@ export const Chat: React.FC = () => {
workFlowSessionId, workFlowSessionId,
recordType, recordType,
busiType: '01', 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记录 */ /** 获取qa记录 */
const getUserQaRecordPage = useCallback(async (conversationId: string, toolId?: string) => { const getUserQaRecordPage = useCallback(async (conversationId: string, toolId?: string) => {
...@@ -799,14 +1176,7 @@ export const Chat: React.FC = () => { ...@@ -799,14 +1176,7 @@ export const Chat: React.FC = () => {
await waitForToken() await waitForToken()
const res = await fetchUserQaRecordPage(finalConversationId, toolId || safeSessionStorageGetItem('currentToolId') || '') const res = await fetchUserQaRecordPage(finalConversationId, toolId || safeSessionStorageGetItem('currentToolId') || '')
const qaRecords = res.data || [] const qaRecords = res.data || []
const wfCtx = getLastQaRecordWorkflowContext(qaRecords) applyLoadedQaWorkflowContext(qaRecords)
lastLoadedQaWorkflowContextRef.current = wfCtx.recordType && wfCtx.workFlowSessionId
? {
recordType: wfCtx.recordType,
workFlowSessionId: wfCtx.workFlowSessionId,
busiType: wfCtx.busiType ?? '01',
}
: null
const messages = [{ role: 'system' } as ChatRecord, ...processApiResponse(qaRecords)] const messages = [{ role: 'system' } as ChatRecord, ...processApiResponse(qaRecords)]
// 处理历史记录中的参考文档标记 // 处理历史记录中的参考文档标记
const processedMessages = messages.map((item) => { const processedMessages = messages.map((item) => {
...@@ -919,8 +1289,8 @@ export const Chat: React.FC = () => { ...@@ -919,8 +1289,8 @@ export const Chat: React.FC = () => {
setTimeout(() => { setTimeout(() => {
// 左侧常见问题自动发送:若 QA 列表最后一条带 recordType + workFlowSessionId,一并透传 // 左侧常见问题自动发送:若 QA 列表最后一条带 recordType + workFlowSessionId,一并透传
const wfExtra = lastLoadedQaWorkflowContextRef.current const wfExtra = lastLoadedQaWorkflowContextRef.current
const autoExtra = wfExtra?.recordType && wfExtra?.workFlowSessionId const autoExtra = wfExtra?.workFlowSessionId
? { recordType: wfExtra.recordType, workFlowSessionId: wfExtra.workFlowSessionId, busiType: wfExtra.busiType ?? '01' } ? { workFlowSessionId: wfExtra.workFlowSessionId, busiType: wfExtra.busiType ?? '01' }
: undefined : undefined
handleSubmitQuestion(questionToSend, undefined, currentToolId, autoExtra) handleSubmitQuestion(questionToSend, undefined, currentToolId, autoExtra)
}, 100) }, 100)
...@@ -951,6 +1321,7 @@ export const Chat: React.FC = () => { ...@@ -951,6 +1321,7 @@ export const Chat: React.FC = () => {
const toolHistoryLoadingId = sessionStorage.getItem('toolHistoryLoading') const toolHistoryLoadingId = sessionStorage.getItem('toolHistoryLoading')
if (toolHistoryLoadingId === id) { if (toolHistoryLoadingId === id) {
currentIdRef.current = id currentIdRef.current = id
workflowBootstrapDoneRef.current = null
// 更新 toolId 相关状态(如果有) // 更新 toolId 相关状态(如果有)
if (initialToolId !== undefined) { if (initialToolId !== undefined) {
toolIdFromStateRef.current = initialToolId toolIdFromStateRef.current = initialToolId
...@@ -987,6 +1358,7 @@ export const Chat: React.FC = () => { ...@@ -987,6 +1358,7 @@ export const Chat: React.FC = () => {
dispatch(setIsAsking(false)) dispatch(setIsAsking(false))
} }
currentIdRef.current = id currentIdRef.current = id
workflowBootstrapDoneRef.current = null
lastSentQuestionRef.current = '' // 重置标记 lastSentQuestionRef.current = '' // 重置标记
pendingQuestionRef.current = null // 清除待发送的问题 pendingQuestionRef.current = null // 清除待发送的问题
// 重置历史记录加载标记,确保新会话时能正确触发自动发送 // 重置历史记录加载标记,确保新会话时能正确触发自动发送
...@@ -1007,6 +1379,22 @@ export const Chat: React.FC = () => { ...@@ -1007,6 +1379,22 @@ export const Chat: React.FC = () => {
} }
}, [id, location.state]) }, [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的变化 - 自动发送问题 // 处理shouldSendQuestion的变化 - 自动发送问题
useEffect(() => { useEffect(() => {
if ( if (
...@@ -1034,8 +1422,8 @@ export const Chat: React.FC = () => { ...@@ -1034,8 +1422,8 @@ export const Chat: React.FC = () => {
setTimeout(() => { setTimeout(() => {
console.log('[Chat] 执行自动发送问题:', questionToSend) console.log('[Chat] 执行自动发送问题:', questionToSend)
const wfExtra = lastLoadedQaWorkflowContextRef.current const wfExtra = lastLoadedQaWorkflowContextRef.current
const autoExtra = wfExtra?.recordType && wfExtra?.workFlowSessionId const autoExtra = wfExtra?.workFlowSessionId
? { recordType: wfExtra.recordType, workFlowSessionId: wfExtra.workFlowSessionId, busiType: wfExtra.busiType ?? '01' } ? { workFlowSessionId: wfExtra.workFlowSessionId, busiType: wfExtra.busiType ?? '01' }
: undefined : undefined
handleSubmitQuestion(questionToSend, undefined, currentToolId, autoExtra) handleSubmitQuestion(questionToSend, undefined, currentToolId, autoExtra)
}, 100) }, 100)
...@@ -1083,8 +1471,8 @@ export const Chat: React.FC = () => { ...@@ -1083,8 +1471,8 @@ export const Chat: React.FC = () => {
} }
else { else {
const list = readToolListFromSessionStorage() const list = readToolListFromSessionStorage()
const general = findGeneralToolRow(list) const first = getFirstToolListRow(list)
setCurrentToolName(general?.toolName?.trim() || undefined) setCurrentToolName(first?.toolName?.trim() || undefined)
} }
} }
getToolNameFromToolId() getToolNameFromToolId()
...@@ -1117,17 +1505,10 @@ export const Chat: React.FC = () => { ...@@ -1117,17 +1505,10 @@ export const Chat: React.FC = () => {
useEffect(() => { useEffect(() => {
const handleToolHistoryLoaded = (event: CustomEvent) => { const handleToolHistoryLoaded = (event: CustomEvent) => {
const { conversationId, toolId, toolName, qaRecords } = event.detail const { conversationId, toolId, toolName, qaRecords } = event.detail
const qaList = Array.isArray(qaRecords) ? qaRecords : []
setIsLoading(true) setIsLoading(true)
try { try {
const qaList = Array.isArray(qaRecords) ? qaRecords : [] applyLoadedQaWorkflowContext(qaList)
const wfEvt = getLastQaRecordWorkflowContext(qaList)
lastLoadedQaWorkflowContextRef.current = wfEvt.recordType && wfEvt.workFlowSessionId
? {
recordType: wfEvt.recordType,
workFlowSessionId: wfEvt.workFlowSessionId,
busiType: wfEvt.busiType ?? '01',
}
: null
// 标记已通过事件加载数据,避免 useEffect 重复调用接口 // 标记已通过事件加载数据,避免 useEffect 重复调用接口
toolHistoryLoadedRef.current.conversationId = conversationId toolHistoryLoadedRef.current.conversationId = conversationId
// 更新当前会话ID // 更新当前会话ID
...@@ -1174,6 +1555,8 @@ export const Chat: React.FC = () => { ...@@ -1174,6 +1555,8 @@ export const Chat: React.FC = () => {
if (toolHistoryLoadingId === conversationId) { if (toolHistoryLoadingId === conversationId) {
sessionStorage.removeItem('toolHistoryLoading') sessionStorage.removeItem('toolHistoryLoading')
} }
sessionStorage.removeItem(PENDING_WORKFLOW_SILENT_BOOTSTRAP_KEY)
runWorkflowSilentBootstrapIfNeeded(conversationId, toolId ?? '', qaList)
} }
} }
window.addEventListener('toolHistoryLoaded', handleToolHistoryLoaded as EventListener) window.addEventListener('toolHistoryLoaded', handleToolHistoryLoaded as EventListener)
...@@ -1182,29 +1565,26 @@ export const Chat: React.FC = () => { ...@@ -1182,29 +1565,26 @@ export const Chat: React.FC = () => {
} }
}, [dispatch]) }, [dispatch])
// 监听自动提问事件(提质增效模式下使用 // 监听自动提问事件(左侧新建会话 / 工具首包等静默提问
useEffect(() => { useEffect(() => {
const handleAutoSubmitQuestion = (event: CustomEvent) => { const handleAutoSubmitQuestion = (event: CustomEvent) => {
const { conversationId, question, toolId, recordType, busiType } = event.detail const { conversationId, toolId, recordType, busiType } = event.detail
// 确保当前会话ID匹配 // 确保当前会话ID匹配
if (conversationId === currentIdRef.current) { if (conversationId === currentIdRef.current) {
const wf = lastLoadedQaWorkflowContextRef.current runWorkflowSilentBootstrapIfNeeded(conversationId, toolId ?? '', null)
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 })
}
} }
else { else {
console.warn('[Chat] conversationId 不匹配,跳过自动提交', { try {
eventConversationId: conversationId, sessionStorage.setItem(PENDING_WORKFLOW_SILENT_BOOTSTRAP_KEY, JSON.stringify({
currentIdRef: currentIdRef.current, conversationId,
}) toolId: toolId ?? '',
recordType,
busiType: busiType ?? '01',
}))
}
catch {
// ignore
}
} }
} }
window.addEventListener('autoSubmitQuestion', handleAutoSubmitQuestion as EventListener) window.addEventListener('autoSubmitQuestion', handleAutoSubmitQuestion as EventListener)
...@@ -1240,20 +1620,24 @@ export const Chat: React.FC = () => { ...@@ -1240,20 +1620,24 @@ export const Chat: React.FC = () => {
? `${record.role}-${recordId}` ? `${record.role}-${recordId}`
: `${record.role}-${index}` : `${record.role}-${index}`
// 如果历史记录中 firstQaFlag 为 true,隐藏 UI 但保留组件渲染(确保自动提交逻辑能执行) const questionText = typeof record.question === 'string' ? record.question.trim() : ''
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 firstAnswer = record.answerList?.[0] const firstAnswer = record.answerList?.[0]
const hasAnswer = typeof firstAnswer?.answer === 'string' && firstAnswer.answer.trim().length > 0 const hasAnswer = typeof firstAnswer?.answer === 'string' && firstAnswer.answer.trim().length > 0
const hasCardList = Array.isArray(firstAnswer?.cardList) && firstAnswer!.cardList.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' const shouldForceShow = record.role === 'ai'
&& hasQuestion && (hasCardList || (hasQuestion && (hasAnswer || hasCardList)))
&& (hasAnswer || hasCardList)
&& firstAnswer?.firstQaFlag !== true
return ( return (
<div <div
className="w-full chatItem mx-auto" className="w-full chatItem mx-auto"
...@@ -1298,7 +1682,14 @@ export const Chat: React.FC = () => { ...@@ -1298,7 +1682,14 @@ export const Chat: React.FC = () => {
</motion.div> </motion.div>
</div> </div>
<ChatEditor <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) => { onToolClick={(isToolBtn, toolId, toolName, shouldChangeStyle, conversationId, isLoading, toolMeta) => {
// 如果传递了 loading 状态,更新 isLoading // 如果传递了 loading 状态,更新 isLoading
if (isLoading !== undefined) { if (isLoading !== undefined) {
......
import React from 'react' import React from 'react'
import { Input } from '@heroui/react' import { Input } from '@heroui/react'
import {
WORKFLOW_FIELD_TYPE,
type WorkflowFieldOption,
type WorkflowFormField,
} from '../../lib/workflowCardForm'
import { CardWorkflowFileField } from './CardWorkflowFileField'
export interface WorkflowFieldOption { export type { WorkflowFieldOption, WorkflowFormField as WorkflowFieldItem }
id?: string
text?: string
type?: string
}
export interface WorkflowFieldItem {
key: string
label: string
type: string
required?: boolean
multiple?: boolean
options?: WorkflowFieldOption[]
}
interface CardWorkflowFieldProps { interface CardWorkflowFieldProps {
field: WorkflowFieldItem field: WorkflowFormField
value: string | string[] value: string | string[]
disabled: boolean disabled: boolean
isUploading?: boolean
showRequiredError?: boolean
onChange: (key: string, value: string | string[]) => void onChange: (key: string, value: string | string[]) => void
onPickFile?: (key: string, file: File) => void | Promise<void>
} }
export const CardWorkflowField: React.FC<CardWorkflowFieldProps> = ({ export const CardWorkflowField: React.FC<CardWorkflowFieldProps> = ({
field, field,
value, value,
disabled, disabled,
showRequiredError = false,
isUploading = false,
onChange, onChange,
onPickFile,
}) => { }) => {
const requiredMark = field.required ? '*' : '' const requiredMark = field.required ? '*' : ''
const label = `${requiredMark}${field.label}` 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) { if (field.multiple) {
const selectedValues = Array.isArray(value) ? value : [] const selectedValues = Array.isArray(value) ? value : []
return ( return (
...@@ -54,6 +69,12 @@ export const CardWorkflowField: React.FC<CardWorkflowFieldProps> = ({ ...@@ -54,6 +69,12 @@ export const CardWorkflowField: React.FC<CardWorkflowFieldProps> = ({
</option> </option>
))} ))}
</select> </select>
{showRequiredError && (
<p className="text-[12px] text-[#f31260] mt-[6px]">
请选择
{field.label}
</p>
)}
</div> </div>
) )
} }
...@@ -78,18 +99,33 @@ export const CardWorkflowField: React.FC<CardWorkflowFieldProps> = ({ ...@@ -78,18 +99,33 @@ export const CardWorkflowField: React.FC<CardWorkflowFieldProps> = ({
</option> </option>
))} ))}
</select> </select>
{showRequiredError && (
<p className="text-[12px] text-[#f31260] mt-[6px]">
请选择
{field.label}
</p>
)}
</div> </div>
) )
} }
return ( return (
<Input <div>
label={label} <Input
value={typeof value === 'string' ? value : ''} label={label}
onChange={e => onChange(field.key, e.target.value)} value={typeof value === 'string' ? value : ''}
isDisabled={disabled} onChange={e => onChange(field.key, e.target.value)}
variant="bordered" isDisabled={disabled}
className="w-full" 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>
) )
} }
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>
)
}
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@heroui/react' import { Button } from '@heroui/react'
import { useSearchParams } from 'react-router-dom' 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' import type { Answer, Attachment } from '@/types/chat'
interface CardWorkflowFormProps { interface CardWorkflowFormProps {
attachment: Attachment attachment: Attachment
answer: Answer answer: Answer
onSubmitWorkflow: (paramMap: Record<string, string>, recordId?: string, workFlowSessionId?: string) => void onSubmitWorkflow: (paramMap: WorkflowParamMap, recordId?: string, workFlowSessionId?: string) => void
isSubmitting?: boolean isSubmitting?: boolean
isCompleted?: boolean isCompleted?: boolean
} }
...@@ -19,170 +33,98 @@ export const CardWorkflowForm: React.FC<CardWorkflowFormProps> = ({ ...@@ -19,170 +33,98 @@ export const CardWorkflowForm: React.FC<CardWorkflowFormProps> = ({
isSubmitting = false, isSubmitting = false,
isCompleted = 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 [searchParams] = useSearchParams()
const hasAutoSubmittedRef = useRef(false) // 防止重复自动提交 const hasAutoSubmittedRef = useRef(false)
const lastFirstQaFlagRef = useRef<boolean | undefined>(undefined) // 记录上次的 firstQaFlag 值 const lastFirstQaFlagRef = useRef<boolean | undefined>(undefined)
const onSubmitWorkflowRef = useRef(onSubmitWorkflow) // 保存 onSubmitWorkflow 的引用,避免依赖项变化导致重复执行 const onSubmitWorkflowRef = useRef(onSubmitWorkflow)
// 更新 onSubmitWorkflowRef 的引用
useEffect(() => { useEffect(() => {
onSubmitWorkflowRef.current = onSubmitWorkflow onSubmitWorkflowRef.current = onSubmitWorkflow
}, [onSubmitWorkflow]) }, [onSubmitWorkflow])
// 获取 workflow 字段(支持 text/select 等),使用 useMemo 稳定引用 const workflowFields = useMemo(
const workflowFields = useMemo<WorkflowFieldItem[]>(() => { () => parseWorkflowFormFields(attachment),
return (attachment.content?.fieldList || []).filter(field => Boolean(field?.key) && Boolean(field?.type)) [attachment.content?.fieldList],
}, [attachment.content?.fieldList]) )
const isFieldValueFilled = (field: WorkflowFieldItem, value: string | string[] | undefined) => { const missingRequiredFields = useMemo(
if (Array.isArray(value)) { () => getWorkflowRequiredMissingFields(workflowFields, formValues, fileSelections),
return value.length > 0 [workflowFields, formValues, fileSelections],
} )
return !!value && value.trim() !== ''
}
const buildParamValue = (value: string | string[] | undefined) => { const hasRequiredMissing = missingRequiredFields.length > 0
if (Array.isArray(value)) {
return value.join(',')
}
return value || ''
}
// 初始化表单值,如果 firstQaFlag 为 true,从 URL query 中读取值
useEffect(() => { useEffect(() => {
const initialValues: Record<string, string | string[]> = {} setFormValues(createInitialWorkflowFormValues(workflowFields, {
searchParams,
if (answer.firstQaFlag === true) { useQueryPreset: answer.firstQaFlag === true,
// 从 URL query 参数中读取值,匹配 fieldList 中的 key }))
workflowFields.forEach((field) => { setFileSelections(createInitialWorkflowFileSelections(workflowFields))
const queryValue = searchParams.get(field.key) setSubmitAttempted(false)
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)
}, [workflowFields, searchParams, answer.firstQaFlag]) }, [workflowFields, searchParams, answer.firstQaFlag])
// 单独处理 firstQaFlag 的变化,重置自动提交标志
useEffect(() => { useEffect(() => {
// 重置自动提交标志,当 firstQaFlag 从 true 变为 false/undefined 时重新评估
if (answer.firstQaFlag !== true) { if (answer.firstQaFlag !== true) {
hasAutoSubmittedRef.current = false hasAutoSubmittedRef.current = false
lastFirstQaFlagRef.current = false // 重置记录的值,确保下次变为 true 时能正确触发 lastFirstQaFlagRef.current = false
} }
else { else if (lastFirstQaFlagRef.current === undefined) {
// 如果 firstQaFlag 为 true,初始化 lastFirstQaFlagRef(如果还没有设置) lastFirstQaFlagRef.current = false
// 这样可以处理组件首次渲染时 firstQaFlag 已经是 true 的情况
if (lastFirstQaFlagRef.current === undefined) {
lastFirstQaFlagRef.current = false // 设置为 false,让自动提交逻辑能正确触发
}
} }
}, [answer.firstQaFlag]) }, [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(() => { useEffect(() => {
// 如果 firstQaFlag 为 true,尝试自动提交
// 检查 firstQaFlag 是否从 false/undefined 变为 true(首次变为 true)
const isFirstQaFlagChangedToTrue = answer.firstQaFlag === true && lastFirstQaFlagRef.current !== true const isFirstQaFlagChangedToTrue = answer.firstQaFlag === true && lastFirstQaFlagRef.current !== true
// 只在 firstQaFlag 首次变为 true 且尚未自动提交时执行
if (isFirstQaFlagChangedToTrue && !hasAutoSubmittedRef.current && !isSubmitting && !isCompleted) { if (isFirstQaFlagChangedToTrue && !hasAutoSubmittedRef.current && !isSubmitting && !isCompleted) {
// 检查是否有字段(workflowFields 不为空)
if (workflowFields.length === 0) { if (workflowFields.length === 0) {
console.log('[CardWorkflowForm] 自动提交:workflowFields 为空,跳过')
// 更新记录的值
lastFirstQaFlagRef.current = answer.firstQaFlag lastFirstQaFlagRef.current = answer.firstQaFlag
return return
} }
// 检查是否所有必填字段都有值(从 URL query 中读取的) const requiredFields = workflowFields.filter(field => field.required)
// 只校验 required === true 的字段 const allRequiredFieldsHaveValues = requiredFields.every(field =>
const requiredFields = workflowFields.filter(field => field.required === true) isWorkflowFieldFilled(field, formValues[field.key], fileSelections[field.key]),
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 })),
})
if (allRequiredFieldsHaveValues) { if (allRequiredFieldsHaveValues) {
// 立即设置 hasAutoSubmittedRef,防止重复提交
hasAutoSubmittedRef.current = true hasAutoSubmittedRef.current = true
// 更新记录的值
lastFirstQaFlagRef.current = answer.firstQaFlag lastFirstQaFlagRef.current = answer.firstQaFlag
// 构建 paramMap const runAutoSubmit = async () => {
const paramMap: Record<string, string> = {} const paramMap = await buildWorkflowParamMapAsync(
workflowFields.forEach((field) => { workflowFields,
const queryValue = searchParams.get(field.key) formValues,
if (field.type === 'select' && field.multiple) { fileSelections,
const values = queryValue ? queryValue.split(',').map(item => item.trim()).filter(Boolean) : [] uploadWorkflowFile,
paramMap[field.key] = values.join(',') )
} onSubmitWorkflowRef.current(paramMap, answer.recordId, answer.workFlowSessionId)
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 timeoutId = window.setTimeout(() => {
void runAutoSubmit()
}, 100)
return () => window.clearTimeout(timeoutId)
} }
else {
console.log('[CardWorkflowForm] 自动提交:必填字段值不完整,跳过', { lastFirstQaFlagRef.current = answer.firstQaFlag
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
}
} }
else { else {
// 更新记录的值(即使不执行自动提交,也要更新记录)
lastFirstQaFlagRef.current = answer.firstQaFlag lastFirstQaFlagRef.current = answer.firstQaFlag
} }
// 移除 onSubmitWorkflow 和 textFields 作为依赖项,使用 useRef 保存引用,避免函数引用变化或 textFields 更新导致重复触发 }, [answer.firstQaFlag, isSubmitting, isCompleted, searchParams, workflowFields, formValues, fileSelections, answer.recordId, answer.workFlowSessionId, answer.conversationId])
// textFields 更新不应该触发重新提交,因为我们已经检查了 firstQaFlag 的变化
}, [answer.firstQaFlag, isSubmitting, isCompleted, searchParams])
const handleFieldChange = (key: string, value: string | string[]) => { const handleFieldChange = (key: string, value: string | string[]) => {
setFormValues(prev => ({ setFormValues(prev => ({
...@@ -191,49 +133,79 @@ export const CardWorkflowForm: React.FC<CardWorkflowFormProps> = ({ ...@@ -191,49 +133,79 @@ export const CardWorkflowForm: React.FC<CardWorkflowFormProps> = ({
})) }))
} }
const handleSubmit = () => { const handlePickFile = async (key: string, file: File) => {
// 构建 paramMap setUploadingFieldKey(key)
const paramMap: Record<string, string> = {} try {
workflowFields.forEach((field) => { const url = await uploadWorkflowFile(file)
paramMap[field.key] = buildParamValue(formValues[field.key]) setFileSelections(prev => ({
}) ...prev,
[key]: { fileName: file.name, file, fileUrls: [url] },
// 获取 recordId 和 workFlowSessionId(从 answer 中获取,第一次可能没有) }))
const recordId = answer.recordId setFormValues(prev => ({
const workFlowSessionId = answer.workFlowSessionId ...prev,
[key]: file.name,
onSubmitWorkflow(paramMap, recordId, workFlowSessionId) }))
}
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 return null
}
// 如果 firstQaFlag 为 true 且已经自动提交,隐藏表单 if (answer.firstQaFlag === true && hasAutoSubmittedRef.current)
if (answer.firstQaFlag === true && hasAutoSubmittedRef.current) {
return null return null
}
const fieldDisabled = isSubmitting || isCompleted || (answer.firstQaFlag === true && hasAutoSubmittedRef.current)
const cardTitle = attachment.name?.trim()
return ( return (
<div className="card-workflow-form"> <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]"> <div className="flex flex-col gap-[12px]">
{workflowFields.map(field => ( {workflowFields.map((field) => {
<div key={field.key}> const showRequiredError = submitAttempted
<CardWorkflowField && field.required
field={field} && !isWorkflowFieldFilled(field, formValues[field.key], fileSelections[field.key])
value={formValues[field.key] ?? (field.type === 'select' && field.multiple ? [] : '')}
onChange={handleFieldChange} return (
disabled={isSubmitting || isCompleted || (answer.firstQaFlag === true && hasAutoSubmittedRef.current)} <div key={field.key}>
/> <CardWorkflowField
</div> 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 <Button
color="primary" color="primary"
onPress={handleSubmit} onPress={() => void handleSubmit()}
isDisabled={isSubmitting || isCompleted || hasRequiredMissing || (answer.firstQaFlag === true && hasAutoSubmittedRef.current)} isDisabled={fieldDisabled || uploadingFieldKey != null || (submitAttempted && hasRequiredMissing) || (answer.firstQaFlag === true && hasAutoSubmittedRef.current)}
isLoading={isSubmitting} isLoading={isSubmitting}
className="mt-[8px]" className="mt-[8px]"
> >
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Button, Link } from '@heroui/react' import { Button, Link } from '@heroui/react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import type { WorkflowParamMap } from '../../lib/workflowCardForm'
import { CardWorkflowForm } from './CardWorkflowForm' import { CardWorkflowForm } from './CardWorkflowForm'
import type { Answer, Attachment } from '@/types/chat' import type { Answer, Attachment } from '@/types/chat'
import AnswerProDetailIcon from '@/assets/svg/answerProDetail.svg?react' import AnswerProDetailIcon from '@/assets/svg/answerProDetail.svg?react'
...@@ -17,7 +18,7 @@ interface ChatAnswerAttachmentProps { ...@@ -17,7 +18,7 @@ interface ChatAnswerAttachmentProps {
answer: Answer answer: Answer
isLastAnswer?: boolean isLastAnswer?: boolean
onSubmitQuestion?: (question: string, productCode?: string, workFlowSessionId?: string, recordId?: string) => void 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 isWorkflowSubmitting?: boolean
isAsking?: boolean isAsking?: boolean
} }
...@@ -181,7 +182,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({ ...@@ -181,7 +182,7 @@ export const ChatAnswerAttachment: React.FC<ChatAnswerAttachmentProps> = ({
answer={answer} answer={answer}
onSubmitWorkflow={onWorkflowSubmit || (() => { })} onSubmitWorkflow={onWorkflowSubmit || (() => { })}
isSubmitting={isWorkflowSubmitting} isSubmitting={isWorkflowSubmitting}
isCompleted={false} isCompleted={answer.workflowFormSubmitted === true}
/> />
)} )}
{/* 其他 card-* 类型 */} {/* 其他 card-* 类型 */}
......
import { Avatar, Button } from '@heroui/react' import { Avatar, Button } from '@heroui/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { WorkflowParamMap } from '../../lib/workflowCardForm'
import { ChatAnswerShower } from './ChatAnswerShower' import { ChatAnswerShower } from './ChatAnswerShower'
import { ChatAnswerParser } from './ChatAnswerParser' import { ChatAnswerParser } from './ChatAnswerParser'
import { ChatAnswerRecommend } from './ChatAnswerRecommend' import { ChatAnswerRecommend } from './ChatAnswerRecommend'
...@@ -17,9 +18,9 @@ interface ChatAnswerBoxProps { ...@@ -17,9 +18,9 @@ interface ChatAnswerBoxProps {
showIndex: number showIndex: number
isLastAnswer: boolean isLastAnswer: boolean
index: number 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 onRecommendLoadingChange?: (loading: boolean) => void
onWorkflowSubmit?: (paramMap: Record<string, string>, recordId?: string) => void onWorkflowSubmit?: (paramMap: WorkflowParamMap, recordId?: string) => void
isWorkflowSubmitting?: boolean isWorkflowSubmitting?: boolean
allItems?: ChatRecord[] // 传入整个会话历史,用于查找最后一个 workFlowSessionId allItems?: ChatRecord[] // 传入整个会话历史,用于查找最后一个 workFlowSessionId
} }
......
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Chip } from '@heroui/react' import { Chip } from '@heroui/react'
import { resolveDisplayAnswerText } from '../../helper'
import type { WorkflowParamMap } from '../../lib/workflowCardForm'
import { ChatAnswerAttachment } from './ChatAnswerAttchment' import { ChatAnswerAttachment } from './ChatAnswerAttchment'
import { ChatAnswerOperate } from './ChatAnswerOperate' import { ChatAnswerOperate } from './ChatAnswerOperate'
import { formatMarkdown } from './markdownFormatter' import { formatMarkdown } from './markdownFormatter'
...@@ -17,7 +19,7 @@ interface ChatAnswerParserProps { ...@@ -17,7 +19,7 @@ interface ChatAnswerParserProps {
onSubmitQuestion: (question: string, productCode?: string, workFlowSessionId?: string, recordId?: string) => void onSubmitQuestion: (question: string, productCode?: string, workFlowSessionId?: string, recordId?: string) => void
/** 是否在当前回答中隐藏点赞/点踩/复制按钮(例如策略页自动分析、重新分析) */ /** 是否在当前回答中隐藏点赞/点踩/复制按钮(例如策略页自动分析、重新分析) */
hideOperate?: boolean hideOperate?: boolean
onWorkflowSubmit?: (paramMap: Record<string, string>, recordId?: string) => void onWorkflowSubmit?: (paramMap: WorkflowParamMap, recordId?: string) => void
isWorkflowSubmitting?: boolean isWorkflowSubmitting?: boolean
isAsking?: boolean isAsking?: boolean
} }
...@@ -41,7 +43,9 @@ function CheckIcon({ ...props }) { ...@@ -41,7 +43,9 @@ function CheckIcon({ ...props }) {
} }
export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer, onTyping, onComplate, answer, isStopTyping, onSubmitQuestion, hideOperate, onWorkflowSubmit, isWorkflowSubmitting, isAsking = false }) => { 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 [displayedText, setDisplayedText] = useState('')
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const [isTyping, setIsTyping] = useState(false) const [isTyping, setIsTyping] = useState(false)
...@@ -122,6 +126,13 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer ...@@ -122,6 +126,13 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
if (isStopTyping) { if (isStopTyping) {
return return
} }
// 仅有 cardList、无正文时跳过打字机,流式过程中即可展示卡片
if (!formatAnswer.length && hasCards) {
setIsTyping(false)
if (answer.endAnswerFlag)
onComplate()
return
}
if (!isTyping) { if (!isTyping) {
onTyping() onTyping()
setIsTyping(true) setIsTyping(true)
...@@ -202,10 +213,7 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer ...@@ -202,10 +213,7 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
</MarkdownDetail> </MarkdownDetail>
</div> </div>
)} )}
{!isTyping {hasCards && (
&& answer.cardList
&& answer.cardList?.length !== 0
&& (
<ChatAnswerAttachment <ChatAnswerAttachment
isLastAnswer={isLastAnswer} isLastAnswer={isLastAnswer}
onSubmitQuestion={onSubmitQuestion} onSubmitQuestion={onSubmitQuestion}
......
import React from 'react' import React from 'react'
import { hasWorkflowFormCards, resolveDisplayAnswerText } from '../../helper'
import type { WorkflowParamMap } from '../../lib/workflowCardForm'
import { formatMarkdown } from './markdownFormatter' import { formatMarkdown } from './markdownFormatter'
import { ChatAnswerAttachment } from './ChatAnswerAttchment' import { ChatAnswerAttachment } from './ChatAnswerAttchment'
import { ChatAnswerOperate } from './ChatAnswerOperate' import { ChatAnswerOperate } from './ChatAnswerOperate'
...@@ -10,7 +12,7 @@ interface ChatAnswerShowerProps { ...@@ -10,7 +12,7 @@ interface ChatAnswerShowerProps {
isLastAnswer: boolean isLastAnswer: boolean
onSubmitQuestion: (question: string, productCode?: string, workFlowSessionId?: string, recordId?: string) => void onSubmitQuestion: (question: string, productCode?: string, workFlowSessionId?: string, recordId?: string) => void
hideOperate?: boolean hideOperate?: boolean
onWorkflowSubmit?: (paramMap: Record<string, string>, recordId?: string) => void onWorkflowSubmit?: (paramMap: WorkflowParamMap, recordId?: string) => void
isWorkflowSubmitting?: boolean isWorkflowSubmitting?: boolean
isAsking?: boolean isAsking?: boolean
} }
...@@ -18,19 +20,27 @@ interface ChatAnswerShowerProps { ...@@ -18,19 +20,27 @@ interface ChatAnswerShowerProps {
export const ChatAnswerShower: React.FC<ChatAnswerShowerProps> = ({ answer, isLastAnswer, onSubmitQuestion, hideOperate: hideOperateProp, onWorkflowSubmit, isWorkflowSubmitting, isAsking = false }) => { 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 hideOperateByCard = (answer.cardList || []).some(attachment => attachment.type === 'box' || attachment?.type?.includes('card-'))
const hideOperate = hideOperateByCard || hideOperateProp 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 ( return (
<div className="answerShower"> <div className="answerShower">
{answer.answer && ( {displayAnswerText && (
<div className={answer.cardList?.length ? 'mb-[12px] sm:mb-[20px]' : ''}> <div className={answer.cardList?.length ? 'mb-[12px] sm:mb-[20px]' : ''}>
<MarkdownDetail> <MarkdownDetail>
{formatMarkdown(answer.answer || '')} {formatMarkdown(displayAnswerText)}
</MarkdownDetail> </MarkdownDetail>
</div> </div>
)} )}
{/* 历史记录(isShow === true)且 firstQaFlag === true 时,不渲染 ChatAnswerAttachment(避免触发自动提交) {/* 历史记录(isShow === true)且 firstQaFlag === true 时,不渲染 ChatAnswerAttachment(避免触发自动提交)
流式返回的新记录(isShow === false)正常渲染 */} 流式返回的新记录(isShow === false)正常渲染 */}
{answer.cardList && answer.cardList?.length !== 0 && !(answer.isShow === true && answer.firstQaFlag === true) && ( {shouldRenderCards && (
<ChatAnswerAttachment <ChatAnswerAttachment
onSubmitQuestion={onSubmitQuestion} onSubmitQuestion={onSubmitQuestion}
isLastAnswer={isLastAnswer} isLastAnswer={isLastAnswer}
......
...@@ -7,12 +7,16 @@ interface ChatItemUserProps { ...@@ -7,12 +7,16 @@ interface ChatItemUserProps {
} }
export const ChatItemUser: React.FC<ChatItemUserProps> = ({ record }) => { export const ChatItemUser: React.FC<ChatItemUserProps> = ({ record }) => {
const question = record.question?.trim()
if (!question)
return null
return ( return (
<div className="chatItemUser"> <div className="chatItemUser">
<div className="text-[#b9b9bb] text-[13px] text-center w-full">{record.qaTime}</div> <div className="text-[#b9b9bb] text-[13px] text-center w-full">{record.qaTime}</div>
<div className="flex justify-end"> <div className="flex justify-end">
<div className="sm:block w-[65px] flex-shrink-0"></div> <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} /> <Avatar className="hidden sm:block ml-[20px] flex-shrink-0" src={AvatarUser} />
</div> </div>
<div className="h-[20px] sm:h-[32px] w-full"></div> <div className="h-[20px] sm:h-[32px] w-full"></div>
......
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 上下文。 * 从 query_user_qa_record_list 返回列表的「最后一条」中提取 workflow 上下文。
* 优先在最后一条记录的 answerList 中从后往前找到同时带有 recordType 与 workFlowSessionId 的答案。 * 优先在最后一条记录的 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): { export function getLastQaRecordWorkflowContext(qaRecords: OriginalRecord[] | null | undefined): {
recordType?: string recordType?: string
workFlowSessionId?: string workFlowSessionId?: string
...@@ -37,22 +332,37 @@ export function processApiResponse(data: OriginalRecord[]): ChatRecord[] { ...@@ -37,22 +332,37 @@ export function processApiResponse(data: OriginalRecord[]): ChatRecord[] {
if (data.length === 0) if (data.length === 0)
return chatRecord return chatRecord
data.forEach((record) => { 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 字段(后端未返回问题文案),则不生成用户问题气泡,避免展示“空气炮” // 如果没有 question 字段(后端未返回问题文案),则不生成用户问题气泡,避免展示“空气炮”
if (record.question) { if (questionText) {
chatRecord.push({ chatRecord.push({
role: 'user', role: 'user',
...record, ...record,
question: questionText,
}) })
} }
if (record.answerList && record.answerList.length > 0) { if (record.answerList && record.answerList.length > 0) {
record.answerList.forEach((answer) => { record.answerList.forEach((answer) => {
answer.isShow = true answer.isShow = true
if (answer.answer)
answer.answer = resolveDisplayAnswerText(answer.answer)
}) })
chatRecord.push({ chatRecord.push({
role: 'ai', role: 'ai',
answerList: record.answerList, answerList: record.answerList,
question: record.question, question: questionText,
toolId: record.toolId, toolId: record.toolId,
}) })
} }
......
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}`
}
...@@ -137,7 +137,7 @@ const conversationSlice = createSlice({ ...@@ -137,7 +137,7 @@ const conversationSlice = createSlice({
state.businessInsightWorkflow.isInSubWorkflow = true state.businessInsightWorkflow.isInSubWorkflow = true
state.businessInsightWorkflow.paramMap = {} state.businessInsightWorkflow.paramMap = {}
}, },
updateBusinessInsightParamMap: (state, action: PayloadAction<Record<string, string>>) => { updateBusinessInsightParamMap: (state, action: PayloadAction<Record<string, string | string[]>>) => {
state.businessInsightWorkflow.paramMap = { state.businessInsightWorkflow.paramMap = {
...state.businessInsightWorkflow.paramMap, ...state.businessInsightWorkflow.paramMap,
...action.payload, ...action.payload,
......
...@@ -91,6 +91,8 @@ export interface Answer { ...@@ -91,6 +91,8 @@ export interface Answer {
qaTime?: number | string qaTime?: number | string
firstQaFlag?: boolean firstQaFlag?: boolean
workFlowSessionId?: string workFlowSessionId?: string
/** 工作流表单已提交(paramMap 已发往流式接口) */
workflowFormSubmitted?: boolean
} }
export interface OriginalRecord { export interface OriginalRecord {
......
...@@ -20,7 +20,7 @@ export interface ConversationState { ...@@ -20,7 +20,7 @@ export interface ConversationState {
mainWorkflowSessionId: string | null mainWorkflowSessionId: string | null
subWorkflowSessionId: string | null subWorkflowSessionId: string | null
subWorkflowRecordType: string | null subWorkflowRecordType: string | null
paramMap: Record<string, string> paramMap: Record<string, string | string[]>
isInSubWorkflow: boolean isInSubWorkflow: boolean
pendingQuestion: string | null pendingQuestion: string | null
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment