440 lines
14 KiB
JavaScript
440 lines
14 KiB
JavaScript
|
|
import { ref } from 'vue'
|
||
|
|
|
||
|
|
import {
|
||
|
|
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||
|
|
GUIDED_ACTION_CONTINUE_FILLING,
|
||
|
|
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||
|
|
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||
|
|
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||
|
|
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||
|
|
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||
|
|
GUIDED_ACTION_START_REIMBURSEMENT,
|
||
|
|
GUIDED_ACTION_START_STATUS_QUERY,
|
||
|
|
GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||
|
|
GUIDED_FLOW_MODE_STATUS_QUERY,
|
||
|
|
applyGuidedReimbursementAnswer,
|
||
|
|
buildGuidedExpenseTypeActions,
|
||
|
|
buildGuidedInterruptionActions,
|
||
|
|
buildGuidedInterruptionText,
|
||
|
|
buildGuidedQueryModeActions,
|
||
|
|
buildGuidedQueryPromptText,
|
||
|
|
buildGuidedQueryStatusActions,
|
||
|
|
buildGuidedReimbursementStartText,
|
||
|
|
buildGuidedReimbursementSummaryText,
|
||
|
|
buildGuidedReviewConfirmationActions,
|
||
|
|
buildGuidedReviewSubmitOptions,
|
||
|
|
buildGuidedStatusQueryStartText,
|
||
|
|
buildGuidedStatusQueryText,
|
||
|
|
buildGuidedStepPromptText,
|
||
|
|
createEmptyGuidedFlowState,
|
||
|
|
createGuidedReimbursementState,
|
||
|
|
createGuidedStatusQueryState,
|
||
|
|
getCurrentGuidedStep,
|
||
|
|
isGuidedFlowActive,
|
||
|
|
isGuidedReimbursementReadyForReview,
|
||
|
|
normalizeGuidedFlowState,
|
||
|
|
resolveGuidedExpenseTypeFromText,
|
||
|
|
resolveGuidedQueryModeFromText,
|
||
|
|
selectGuidedExpenseType,
|
||
|
|
selectGuidedQueryMode,
|
||
|
|
shouldConfirmGuidedInterruption
|
||
|
|
} from './travelReimbursementGuidedFlowModel.js'
|
||
|
|
|
||
|
|
function normalizeText(value) {
|
||
|
|
return String(value || '').trim()
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildFileNames(files) {
|
||
|
|
return Array.from(files || [])
|
||
|
|
.map((file) => normalizeText(file?.name))
|
||
|
|
.filter(Boolean)
|
||
|
|
}
|
||
|
|
|
||
|
|
function mergePendingFiles(currentFiles, nextFiles) {
|
||
|
|
const merged = [...Array.from(currentFiles || [])]
|
||
|
|
Array.from(nextFiles || []).forEach((file) => {
|
||
|
|
const name = normalizeText(file?.name)
|
||
|
|
if (!name) return
|
||
|
|
const duplicated = merged.some((item) => normalizeText(item?.name) === name && Number(item?.size || 0) === Number(file?.size || 0))
|
||
|
|
if (!duplicated) {
|
||
|
|
merged.push(file)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
return merged
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useTravelReimbursementGuidedFlow({
|
||
|
|
guidedFlowState,
|
||
|
|
messages,
|
||
|
|
composerDraft,
|
||
|
|
attachedFiles,
|
||
|
|
composerBusinessTimeTags,
|
||
|
|
composerBusinessTimeDraftTouched,
|
||
|
|
fileInputRef,
|
||
|
|
submitting,
|
||
|
|
reviewActionBusy,
|
||
|
|
sessionSwitchBusy,
|
||
|
|
createMessage,
|
||
|
|
nextTick,
|
||
|
|
scrollToBottom,
|
||
|
|
persistSessionState,
|
||
|
|
clearAttachedFiles,
|
||
|
|
adjustComposerTextareaHeight,
|
||
|
|
buildComposerBusinessTimeContext,
|
||
|
|
openTravelCalculator,
|
||
|
|
lockSuggestedActionMessage,
|
||
|
|
submitExistingComposer,
|
||
|
|
toast
|
||
|
|
}) {
|
||
|
|
const guidedPendingFiles = ref([])
|
||
|
|
|
||
|
|
function persistAndScroll() {
|
||
|
|
persistSessionState()
|
||
|
|
nextTick(() => {
|
||
|
|
adjustComposerTextareaHeight?.()
|
||
|
|
scrollToBottom?.()
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearComposerRuntime() {
|
||
|
|
composerDraft.value = ''
|
||
|
|
clearAttachedFiles?.()
|
||
|
|
if (fileInputRef?.value) {
|
||
|
|
fileInputRef.value.value = ''
|
||
|
|
}
|
||
|
|
if (composerBusinessTimeTags) {
|
||
|
|
composerBusinessTimeTags.value = []
|
||
|
|
}
|
||
|
|
if (composerBusinessTimeDraftTouched) {
|
||
|
|
composerBusinessTimeDraftTouched.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function pushAssistant(text, extras = {}) {
|
||
|
|
messages.value.push(createMessage('assistant', text, [], extras))
|
||
|
|
}
|
||
|
|
|
||
|
|
function pushUser(text, attachmentNames = []) {
|
||
|
|
const normalizedText = normalizeText(text)
|
||
|
|
messages.value.push(createMessage('user', normalizedText || `上传 ${attachmentNames.length} 份附件`, attachmentNames))
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetGuidedFlowState() {
|
||
|
|
guidedFlowState.value = createEmptyGuidedFlowState()
|
||
|
|
guidedPendingFiles.value = []
|
||
|
|
}
|
||
|
|
|
||
|
|
function startGuidedReimbursement() {
|
||
|
|
guidedFlowState.value = createGuidedReimbursementState()
|
||
|
|
guidedPendingFiles.value = []
|
||
|
|
pushAssistant(buildGuidedReimbursementStartText(), {
|
||
|
|
meta: ['引导式报销'],
|
||
|
|
suggestedActions: buildGuidedExpenseTypeActions()
|
||
|
|
})
|
||
|
|
persistAndScroll()
|
||
|
|
}
|
||
|
|
|
||
|
|
function startGuidedStatusQuery() {
|
||
|
|
guidedFlowState.value = createGuidedStatusQueryState()
|
||
|
|
guidedPendingFiles.value = []
|
||
|
|
pushAssistant(buildGuidedStatusQueryStartText(), {
|
||
|
|
meta: ['引导式查询'],
|
||
|
|
suggestedActions: buildGuidedQueryModeActions()
|
||
|
|
})
|
||
|
|
persistAndScroll()
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleGuidedShortcut(shortcut) {
|
||
|
|
const actionType = normalizeText(shortcut?.action)
|
||
|
|
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
|
||
|
|
startGuidedReimbursement()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
if (actionType === GUIDED_ACTION_START_STATUS_QUERY) {
|
||
|
|
startGuidedStatusQuery()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) {
|
||
|
|
openTravelCalculator?.()
|
||
|
|
pushAssistant('差旅计算器已打开。你可以直接填写目的地、天数和金额,我会按规则中心标准帮你测算。', {
|
||
|
|
meta: ['差旅计算器']
|
||
|
|
})
|
||
|
|
persistAndScroll()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildAnswerText(rawText, state) {
|
||
|
|
const text = normalizeText(rawText)
|
||
|
|
if (text) {
|
||
|
|
return text
|
||
|
|
}
|
||
|
|
const currentStep = getCurrentGuidedStep(state)
|
||
|
|
if (currentStep?.key === 'time_range') {
|
||
|
|
const businessTimeContext = buildComposerBusinessTimeContext?.()
|
||
|
|
return normalizeText(businessTimeContext?.time_range || businessTimeContext?.business_time)
|
||
|
|
}
|
||
|
|
return ''
|
||
|
|
}
|
||
|
|
|
||
|
|
function pushNextReimbursementPrompt() {
|
||
|
|
pushAssistant(buildGuidedStepPromptText(guidedFlowState.value), {
|
||
|
|
meta: ['引导式报销']
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function pushReimbursementSummary() {
|
||
|
|
pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), {
|
||
|
|
meta: ['待生成核对信息'],
|
||
|
|
suggestedActions: buildGuidedReviewConfirmationActions()
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleReimbursementAnswer(answerText, files) {
|
||
|
|
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||
|
|
const currentStep = getCurrentGuidedStep(currentState)
|
||
|
|
const fileNames = buildFileNames(files)
|
||
|
|
|
||
|
|
if (currentState.stepKey === 'expense_type') {
|
||
|
|
const expenseType = resolveGuidedExpenseTypeFromText(answerText)
|
||
|
|
if (!expenseType) {
|
||
|
|
pushAssistant('我还需要先确认报销类型。请点击下面最贴近的费用场景后,我再继续问下一项。', {
|
||
|
|
meta: ['等待选择报销类型'],
|
||
|
|
suggestedActions: buildGuidedExpenseTypeActions()
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
guidedFlowState.value = selectGuidedExpenseType(currentState, expenseType)
|
||
|
|
pushNextReimbursementPrompt()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!currentStep) {
|
||
|
|
pushAssistant(buildGuidedReimbursementStartText(), {
|
||
|
|
meta: ['引导式报销'],
|
||
|
|
suggestedActions: buildGuidedExpenseTypeActions()
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!answerText && fileNames.length && currentStep.key !== 'attachments') {
|
||
|
|
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
|
||
|
|
pushAssistant([
|
||
|
|
`我已先记录 ${fileNames.length} 份附件。`,
|
||
|
|
'',
|
||
|
|
`当前还需要补充:${currentStep.summaryLabel}。`,
|
||
|
|
currentStep.prompt
|
||
|
|
].join('\n'), {
|
||
|
|
meta: ['已记录附件']
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fileNames.length) {
|
||
|
|
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
|
||
|
|
}
|
||
|
|
guidedFlowState.value = applyGuidedReimbursementAnswer(currentState, answerText, fileNames)
|
||
|
|
if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) {
|
||
|
|
pushReimbursementSummary()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
pushNextReimbursementPrompt()
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runStatusQuery(queryText, skipUserMessage = true) {
|
||
|
|
const normalizedQuery = normalizeText(queryText)
|
||
|
|
resetGuidedFlowState()
|
||
|
|
clearComposerRuntime()
|
||
|
|
persistAndScroll()
|
||
|
|
if (!normalizedQuery) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
await submitExistingComposer({
|
||
|
|
rawText: normalizedQuery,
|
||
|
|
userText: normalizedQuery,
|
||
|
|
pendingText: '正在查询单据状态...',
|
||
|
|
skipUserMessage
|
||
|
|
})
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleStatusQueryAnswer(answerText) {
|
||
|
|
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||
|
|
if (currentState.stepKey === 'query_mode') {
|
||
|
|
const queryMode = resolveGuidedQueryModeFromText(answerText)
|
||
|
|
if (!queryMode) {
|
||
|
|
pushAssistant(buildGuidedStatusQueryStartText(), {
|
||
|
|
meta: ['引导式查询'],
|
||
|
|
suggestedActions: buildGuidedQueryModeActions()
|
||
|
|
})
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
guidedFlowState.value = selectGuidedQueryMode(currentState, queryMode)
|
||
|
|
const actions = guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||
|
|
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
|
||
|
|
meta: ['引导式查询'],
|
||
|
|
suggestedActions: actions
|
||
|
|
})
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
const queryText = buildGuidedStatusQueryText(currentState, answerText)
|
||
|
|
return runStatusQuery(queryText, true)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleGuidedComposerSubmit(options = {}) {
|
||
|
|
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||
|
|
if (!isGuidedFlowActive(currentState)) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if (options.systemGenerated || normalizeText(options.extraContext?.review_action)) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
const files = Array.from(options.files ?? attachedFiles.value ?? [])
|
||
|
|
const fileNames = buildFileNames(files)
|
||
|
|
const answerText = buildAnswerText(options.rawText ?? composerDraft.value, currentState)
|
||
|
|
if (!answerText && !fileNames.length) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
pushUser(answerText, fileNames)
|
||
|
|
if (shouldConfirmGuidedInterruption(answerText, currentState) && !fileNames.length) {
|
||
|
|
guidedFlowState.value = {
|
||
|
|
...currentState,
|
||
|
|
pendingInterruptionText: answerText
|
||
|
|
}
|
||
|
|
pushAssistant(buildGuidedInterruptionText(answerText), {
|
||
|
|
meta: ['等待确认是否打断'],
|
||
|
|
suggestedActions: buildGuidedInterruptionActions()
|
||
|
|
})
|
||
|
|
clearComposerRuntime()
|
||
|
|
persistAndScroll()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
|
||
|
|
handleReimbursementAnswer(answerText, files)
|
||
|
|
clearComposerRuntime()
|
||
|
|
persistAndScroll()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (currentState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
|
||
|
|
clearComposerRuntime()
|
||
|
|
persistAndScroll()
|
||
|
|
await handleStatusQueryAnswer(answerText)
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleGuidedSuggestedAction(message, action) {
|
||
|
|
const actionType = normalizeText(action?.action_type)
|
||
|
|
if (!actionType) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
const guidedActionTypes = new Set([
|
||
|
|
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||
|
|
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||
|
|
GUIDED_ACTION_CONTINUE_FILLING,
|
||
|
|
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||
|
|
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||
|
|
GUIDED_ACTION_SELECT_QUERY_STATUS
|
||
|
|
])
|
||
|
|
if (!guidedActionTypes.has(actionType)) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value || message?.suggestedActionsLocked) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
if (!lockSuggestedActionMessage(message, action)) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
|
||
|
|
const expenseType = normalizeText(action?.payload?.expense_type)
|
||
|
|
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
|
||
|
|
guidedFlowState.value = selectGuidedExpenseType(guidedFlowState.value, expenseType)
|
||
|
|
pushUser(`选择${expenseTypeLabel || '报销类型'}`)
|
||
|
|
pushNextReimbursementPrompt()
|
||
|
|
persistAndScroll()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (actionType === GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW) {
|
||
|
|
const submitOptions = buildGuidedReviewSubmitOptions(guidedFlowState.value, guidedPendingFiles.value)
|
||
|
|
resetGuidedFlowState()
|
||
|
|
persistAndScroll()
|
||
|
|
await submitExistingComposer(submitOptions)
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (actionType === GUIDED_ACTION_CONTINUE_FILLING) {
|
||
|
|
const pendingState = {
|
||
|
|
...normalizeGuidedFlowState(guidedFlowState.value),
|
||
|
|
pendingInterruptionText: ''
|
||
|
|
}
|
||
|
|
guidedFlowState.value = pendingState
|
||
|
|
if (pendingState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
|
||
|
|
pushAssistant(buildGuidedQueryPromptText(pendingState), {
|
||
|
|
meta: ['引导式查询'],
|
||
|
|
suggestedActions: pendingState.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
pushNextReimbursementPrompt()
|
||
|
|
}
|
||
|
|
persistAndScroll()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (actionType === GUIDED_ACTION_PROCESS_INTERRUPTION) {
|
||
|
|
const pendingText = normalizeText(guidedFlowState.value?.pendingInterruptionText)
|
||
|
|
resetGuidedFlowState()
|
||
|
|
persistAndScroll()
|
||
|
|
await submitExistingComposer({
|
||
|
|
rawText: pendingText,
|
||
|
|
userText: pendingText,
|
||
|
|
pendingText: '正在处理你的问题...',
|
||
|
|
skipUserMessage: true
|
||
|
|
})
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (actionType === GUIDED_ACTION_SELECT_QUERY_MODE) {
|
||
|
|
const queryMode = normalizeText(action?.payload?.query_mode)
|
||
|
|
const queryModeLabel = normalizeText(action?.payload?.query_mode_label || action?.label)
|
||
|
|
guidedFlowState.value = selectGuidedQueryMode(guidedFlowState.value, queryMode)
|
||
|
|
pushUser(`选择${queryModeLabel || '查询方式'}`)
|
||
|
|
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
|
||
|
|
meta: ['引导式查询'],
|
||
|
|
suggestedActions: guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
|
||
|
|
})
|
||
|
|
persistAndScroll()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (actionType === GUIDED_ACTION_SELECT_QUERY_STATUS) {
|
||
|
|
const statusLabel = normalizeText(action?.payload?.query_status_label || action?.label)
|
||
|
|
pushUser(`选择${statusLabel || '单据状态'}`)
|
||
|
|
const queryText = buildGuidedStatusQueryText(guidedFlowState.value, statusLabel)
|
||
|
|
await runStatusQuery(queryText, true)
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
handleGuidedShortcut,
|
||
|
|
handleGuidedComposerSubmit,
|
||
|
|
handleGuidedSuggestedAction,
|
||
|
|
resetGuidedFlowState
|
||
|
|
}
|
||
|
|
}
|