- 新增 travelReimbursementAssociationGateModel,查询可关联申请单/草稿报销单并生成跳过/选择/单独新建动作,区分差旅费与业务招待费类型 - travelReimbursementApplicationLinkModel 补充 buildLinkedApplicationReferenceIndex/buildRequiredApplicationActions 等关联构建逻辑 - useTravelReimbursementSuggestedActions 接入 select_required_application/skip 系列动作,'我要报销'入口改为先走关联门控 - useWorkbenchAiActionRouter 新增 SKIP_REQUIRED_APPLICATION_LINK/SKIP_REIMBURSEMENT_DRAFT_CHECK 动作分发 - useWorkbenchAiExpenseFlow 暴露 startAiReimbursementAssociationGate,stewardPlanModel 待处理流程适配 - 新增 workbench-ai-action-router、workbench-ai-reimbursement-association-gate 测试并更新 guided-flow、steward-plan 测试
507 lines
19 KiB
JavaScript
507 lines
19 KiB
JavaScript
import {
|
||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||
normalizeApplicationPreview,
|
||
normalizeTransportModeOption
|
||
} from '../../utils/expenseApplicationPreview.js'
|
||
import {
|
||
mergeComposerPrefill,
|
||
resolveSuggestedActionPrefill
|
||
} from '../../utils/assistantSuggestedActionPrefill.js'
|
||
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
|
||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||
import {
|
||
TRAVEL_PLANNING_ACTION_GENERATE,
|
||
TRAVEL_PLANNING_ACTION_SKIP,
|
||
buildTravelPlanningRecommendation
|
||
} from '../../utils/travelApplicationPlanning.js'
|
||
import {
|
||
SESSION_TYPE_APPLICATION,
|
||
SESSION_TYPE_BUDGET,
|
||
SESSION_TYPE_EXPENSE,
|
||
canUseBudgetAssistantSession
|
||
} from './travelReimbursementConversationModel.js'
|
||
import {
|
||
SKIP_REQUIRED_APPLICATION_LINK_ACTION,
|
||
SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION,
|
||
buildReimbursementAssociationSubmitOptions,
|
||
pushReimbursementAssociationPromptMessage
|
||
} from './travelReimbursementAssociationGateModel.js'
|
||
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
|
||
import {
|
||
buildStewardFieldCompletionContinuation,
|
||
buildStewardFieldCompletionRawText
|
||
} from './stewardFieldCompletionModel.js'
|
||
import { MAX_ATTACHMENTS, VISIBLE_ATTACHMENT_CHIPS } from './travelReimbursementAttachmentModel.js'
|
||
|
||
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||
|
||
export function useTravelReimbursementSuggestedActions({
|
||
applicationPreviewEditor,
|
||
attachedFiles,
|
||
buildExpenseSceneSelectionActions,
|
||
buildExpenseSceneSelectionMessage,
|
||
commitApplicationPreviewEditor,
|
||
composerDraft,
|
||
composerFilesExpanded,
|
||
composerTextareaRef,
|
||
createMessage,
|
||
currentUser,
|
||
emit,
|
||
fetchExpenseClaims = async () => ({ items: [] }),
|
||
handleGuidedShortcut,
|
||
handleGuidedSuggestedAction,
|
||
handleSceneSelectionApplicationGate,
|
||
lockSuggestedActionMessage,
|
||
mergeFilesWithLimit,
|
||
messages,
|
||
nextTick,
|
||
openApplicationPreviewEditor,
|
||
persistSessionState,
|
||
resolveApplicationPreviewMissingFields,
|
||
reviewActionBusy,
|
||
router,
|
||
scrollToBottom,
|
||
sessionSwitchBusy,
|
||
startExpenseSceneSelectionAfterIntentConfirmation,
|
||
submitComposer,
|
||
submitComposerInternal,
|
||
submitting,
|
||
switchSessionType,
|
||
toast,
|
||
adjustComposerTextareaHeight
|
||
}) {
|
||
async function runShortcut(shortcut) {
|
||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||
if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
|
||
toast('目前暂无权限访问预算编制助手')
|
||
return
|
||
}
|
||
if (shortcut.active) {
|
||
return
|
||
}
|
||
await switchSessionType(shortcut.targetSessionType)
|
||
return
|
||
}
|
||
if (await handleGuidedShortcut(shortcut)) {
|
||
return
|
||
}
|
||
|
||
const prompt = String(shortcut?.prompt || '').trim()
|
||
if (!prompt) return
|
||
composerDraft.value = prompt
|
||
submitComposer()
|
||
}
|
||
|
||
function isSuggestedActionSelected(message, action) {
|
||
const selectedKey = String(message?.selectedSuggestedActionKey || '').trim()
|
||
return Boolean(selectedKey) && selectedKey === buildSuggestedActionKey(action)
|
||
}
|
||
|
||
function buildApplicationPreviewFieldAppliedText(message, fieldLabel = '', value = '') {
|
||
const missingFields = resolveApplicationPreviewMissingFields(message)
|
||
const resolvedFieldLabel = String(fieldLabel || '补充项').trim()
|
||
const resolvedValue = String(value || '').trim()
|
||
if (missingFields.length) {
|
||
return [
|
||
`已更新:**${resolvedFieldLabel}:${resolvedValue}**。`,
|
||
'',
|
||
`我重新检查了一遍,当前还需要补充:**${missingFields.join('、')}**。`,
|
||
'',
|
||
'请继续补齐下方核对表里的待补充项;补齐后我再继续推进申请提交。'
|
||
].join('\n')
|
||
}
|
||
return [
|
||
`已更新:**${resolvedFieldLabel}:${resolvedValue}**。`,
|
||
'',
|
||
'我已经重新同步下方申请核对表和费用测算。',
|
||
'',
|
||
'请继续核查表格内容;如果信息无误,点击确认进入审批环节。'
|
||
].join('\n')
|
||
}
|
||
|
||
function isStewardApplicationPreviewFieldCompletion(targetMessage, payload = {}) {
|
||
return Boolean(
|
||
payload.steward_delegated_field_completion ||
|
||
String(targetMessage?.assistantName || '').trim() === STEWARD_ASSISTANT_NAME ||
|
||
targetMessage?.stewardPlan
|
||
)
|
||
}
|
||
|
||
async function continueStewardApplicationFieldCompletion({
|
||
targetMessage,
|
||
action,
|
||
sourcePreview,
|
||
fieldKey,
|
||
fieldLabel,
|
||
value
|
||
}) {
|
||
if (!lockSuggestedActionMessage(targetMessage, action)) {
|
||
return true
|
||
}
|
||
|
||
const continuation = buildStewardFieldCompletionContinuation(
|
||
targetMessage?.stewardContinuation || null,
|
||
fieldKey,
|
||
value
|
||
)
|
||
const userText = `选择${fieldLabel || '补充项'}:${value}`
|
||
const carryText = buildStewardFieldCompletionRawText({
|
||
preview: sourcePreview,
|
||
fieldKey,
|
||
fieldLabel,
|
||
value,
|
||
continuation
|
||
})
|
||
|
||
if (!action?.suppressUserEcho) {
|
||
messages.value.push(createMessage('user', userText))
|
||
}
|
||
persistSessionState()
|
||
nextTick(scrollToBottom)
|
||
|
||
await submitComposerInternal({
|
||
rawText: carryText,
|
||
userText,
|
||
pendingText: '小财管家正在根据补齐信息查询票据并测算费用...',
|
||
files: [],
|
||
skipScopeGuard: true,
|
||
skipApplicationModelReview: true,
|
||
skipStewardPlan: true,
|
||
skipUserMessage: true,
|
||
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
||
stewardContinuation: continuation
|
||
})
|
||
return true
|
||
}
|
||
|
||
async function applyApplicationPreviewFieldAction(message, action) {
|
||
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
|
||
const fieldLabel = String(payload.field_label || payload.fieldLabel || action?.label || '').trim()
|
||
let value = String(payload.value || action?.label || '').trim()
|
||
const targetMessage = messages.value.find((item) => String(item.id || '') === String(message?.id || '')) || message
|
||
const sourcePreview = targetMessage?.applicationPreview ||
|
||
payload.applicationPreview ||
|
||
payload.application_preview ||
|
||
payload.preview ||
|
||
null
|
||
if (!sourcePreview || !fieldKey || !value) {
|
||
return false
|
||
}
|
||
if (fieldKey === 'transportMode') {
|
||
value = normalizeTransportModeOption(value, '')
|
||
}
|
||
if (fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(value)) {
|
||
toast('请选择有效的出行方式。')
|
||
return true
|
||
}
|
||
if (isStewardApplicationPreviewFieldCompletion(targetMessage, payload)) {
|
||
return continueStewardApplicationFieldCompletion({
|
||
targetMessage,
|
||
action,
|
||
sourcePreview,
|
||
fieldKey,
|
||
fieldLabel,
|
||
value
|
||
})
|
||
}
|
||
if (!lockSuggestedActionMessage(targetMessage, action)) {
|
||
return true
|
||
}
|
||
|
||
targetMessage.applicationPreview = normalizeApplicationPreview(sourcePreview)
|
||
messages.value.push(createMessage('user', `选择${fieldLabel || '补充项'}:${value}`))
|
||
openApplicationPreviewEditor(targetMessage, fieldKey, targetMessage.applicationPreview?.fields?.[fieldKey] || '')
|
||
applicationPreviewEditor.value = {
|
||
...applicationPreviewEditor.value,
|
||
draftValue: value
|
||
}
|
||
await commitApplicationPreviewEditor(targetMessage)
|
||
if (String(targetMessage.assistantName || '').trim() === STEWARD_ASSISTANT_NAME || targetMessage.stewardPlan) {
|
||
targetMessage.assistantName = STEWARD_ASSISTANT_NAME
|
||
targetMessage.text = buildApplicationPreviewFieldAppliedText(targetMessage, fieldLabel, value)
|
||
const nextMeta = Array.isArray(targetMessage.meta) ? targetMessage.meta : []
|
||
targetMessage.meta = Array.from(new Set([
|
||
STEWARD_ASSISTANT_NAME,
|
||
resolveApplicationPreviewMissingFields(targetMessage).length ? '等待补充' : '等待用户确认',
|
||
...nextMeta.filter((item) => String(item || '').trim() && item !== STEWARD_ASSISTANT_NAME)
|
||
])).slice(0, 4)
|
||
}
|
||
persistSessionState()
|
||
nextTick(scrollToBottom)
|
||
return true
|
||
}
|
||
|
||
function pushExpenseSceneSelectionPrompt(originalMessage, userEcho = '我要报销') {
|
||
const sourceText = String(originalMessage || '').trim()
|
||
if (!sourceText) {
|
||
return
|
||
}
|
||
|
||
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
|
||
messages.value.push(createMessage('user', String(userEcho || '我要报销').trim() || '我要报销'))
|
||
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
|
||
meta: ['等待选择场景'],
|
||
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
|
||
}))
|
||
nextTick(scrollToBottom)
|
||
persistSessionState()
|
||
}
|
||
|
||
async function pushExpenseAssociationGatePrompt(originalMessage, options = {}) {
|
||
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
|
||
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
|
||
messages.value.push(createMessage('user', String(options.userText || '我要报销').trim() || '我要报销'))
|
||
await pushReimbursementAssociationPromptMessage({
|
||
rawText: sourceText,
|
||
createMessage,
|
||
messages,
|
||
nextTick,
|
||
scrollToBottom,
|
||
persistSessionState,
|
||
fetchExpenseClaims,
|
||
currentUser,
|
||
skipDraftCheck: Boolean(options.skipDraftCheck)
|
||
})
|
||
}
|
||
|
||
function applySuggestedActionPrefill(action) {
|
||
const prefillText = resolveSuggestedActionPrefill(action)
|
||
if (!prefillText) {
|
||
return false
|
||
}
|
||
|
||
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
|
||
nextTick(() => {
|
||
adjustComposerTextareaHeight()
|
||
composerTextareaRef.value?.focus()
|
||
})
|
||
persistSessionState()
|
||
return true
|
||
}
|
||
|
||
async function handleSuggestedAction(message, action) {
|
||
const actionType = String(action?.action_type || '').trim()
|
||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||
if (message?.suggestedActionsLocked) return
|
||
if (applySuggestedActionPrefill(action)) return
|
||
if (await handleGuidedSuggestedAction(message, action)) return
|
||
if (await handleSceneSelectionApplicationGate(message, action)) return
|
||
|
||
if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) {
|
||
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
await pushExpenseAssociationGatePrompt(originalMessage, {
|
||
skipDraftCheck: true,
|
||
userText: action?.label || '不用草稿,关联申请单新建报销单'
|
||
})
|
||
return
|
||
}
|
||
|
||
if (actionType === SKIP_REQUIRED_APPLICATION_LINK_ACTION) {
|
||
const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销'
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
pushExpenseSceneSelectionPrompt(originalMessage, action?.label || '不关联,单独新建报销单')
|
||
return
|
||
}
|
||
|
||
if (actionType === 'select_required_application') {
|
||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||
const applicationNo = String(actionPayload.application_claim_no || actionPayload.claim_no || '').trim()
|
||
const originalMessage = String(actionPayload.original_message || message?.text || '我要报销').trim() || '我要报销'
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
messages.value.push(createMessage('user', `关联申请单 ${applicationNo || ''}`.trim() || '关联申请单'))
|
||
nextTick(scrollToBottom)
|
||
persistSessionState()
|
||
await submitComposer(buildReimbursementAssociationSubmitOptions(actionPayload, originalMessage))
|
||
return
|
||
}
|
||
|
||
if (actionType === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
|
||
await applyApplicationPreviewFieldAction(message, action)
|
||
return
|
||
}
|
||
|
||
if (actionType === 'open_application_detail') {
|
||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
|
||
if (!claimId) {
|
||
toast('当前没有可查看的申请单据。')
|
||
return
|
||
}
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
await router.push({
|
||
name: 'app-document-detail',
|
||
params: { requestId: claimId }
|
||
})
|
||
emit('close')
|
||
return
|
||
}
|
||
|
||
if (actionType === 'open_receipt_folder') {
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
await router.push({ name: 'app-receiptFolder' })
|
||
emit('close')
|
||
return
|
||
}
|
||
|
||
if (actionType === 'continue_upload_with_unlinked_receipts') {
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||
await submitComposer({
|
||
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
|
||
files: Array.from(attachedFiles.value || []),
|
||
skipReceiptFolderUnlinkedPrompt: true
|
||
})
|
||
return
|
||
}
|
||
|
||
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
|
||
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
|
||
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
|
||
if (recommendation) {
|
||
messages.value.push(createMessage('user', '生成行程规划'))
|
||
messages.value.push(createMessage('assistant', recommendation, [], {
|
||
meta: ['行程规划建议']
|
||
}))
|
||
nextTick(scrollToBottom)
|
||
persistSessionState()
|
||
}
|
||
return
|
||
}
|
||
|
||
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
|
||
meta: ['暂不规划']
|
||
}))
|
||
nextTick(scrollToBottom)
|
||
persistSessionState()
|
||
return
|
||
}
|
||
|
||
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||
const targetSessionType = String(actionPayload.session_type || '').trim()
|
||
if (!targetSessionType) return
|
||
if (targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
|
||
toast('目前暂无权限访问预算编制助手')
|
||
return
|
||
}
|
||
const carryText = String(actionPayload.carry_text || '').trim()
|
||
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
||
await pushExpenseAssociationGatePrompt(carryText)
|
||
return
|
||
}
|
||
if (String(actionPayload.steward_plan_id || '').trim()) {
|
||
const confirmedByText = Boolean(action.confirmedByText)
|
||
delete action.confirmedByText
|
||
await submitComposerInternal({
|
||
rawText: carryText,
|
||
userText: action.label || '确定',
|
||
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
|
||
? '小财管家正在调用申请助手生成申请单核对结果...'
|
||
: '小财管家正在调用报销助手整理报销核对结果...',
|
||
files: carryFiles,
|
||
skipScopeGuard: true,
|
||
skipApplicationModelReview: targetSessionType === SESSION_TYPE_APPLICATION,
|
||
skipStewardSlotDecision: targetSessionType === SESSION_TYPE_APPLICATION,
|
||
skipStewardPlan: true,
|
||
skipUserMessage: confirmedByText,
|
||
sessionTypeOverride: targetSessionType,
|
||
stewardContinuation: {
|
||
planId: String(actionPayload.steward_plan_id || '').trim(),
|
||
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
|
||
currentTask: actionPayload.steward_current_task || null,
|
||
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
|
||
? actionPayload.steward_remaining_tasks
|
||
: []
|
||
}
|
||
})
|
||
return
|
||
}
|
||
await switchSessionType(targetSessionType)
|
||
if (carryText) {
|
||
composerDraft.value = carryText
|
||
}
|
||
if (carryFiles.length) {
|
||
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
|
||
attachedFiles.value = fileMergeResult.files
|
||
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
|
||
}
|
||
nextTick(() => {
|
||
adjustComposerTextareaHeight()
|
||
scrollToBottom()
|
||
})
|
||
persistSessionState()
|
||
if (actionPayload.auto_submit && carryText) {
|
||
await submitComposer({
|
||
rawText: carryText,
|
||
userText: action.label || '确认继续处理',
|
||
pendingText: '正在按确认内容继续处理...',
|
||
files: carryFiles,
|
||
skipScopeGuard: true
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
if (actionType === 'confirm_expense_intent') {
|
||
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
||
if (!originalMessage) return
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
await pushExpenseAssociationGatePrompt(originalMessage)
|
||
return
|
||
}
|
||
|
||
if (actionType !== 'select_expense_type') {
|
||
const fallbackText = String(action?.description || action?.label || '').trim()
|
||
if (!fallbackText) return
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
await submitComposer({
|
||
rawText: fallbackText,
|
||
userText: fallbackText,
|
||
pendingText: '正在继续处理...'
|
||
})
|
||
return
|
||
}
|
||
|
||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||
const expenseType = String(actionPayload.expense_type || '').trim()
|
||
const expenseTypeLabel = String(actionPayload.expense_type_label || action?.label || '').trim()
|
||
const originalMessage = String(actionPayload.original_message || message?.text || '').trim()
|
||
if (!expenseTypeLabel || !originalMessage) return
|
||
|
||
if (!lockSuggestedActionMessage(message, action)) return
|
||
await submitComposer({
|
||
rawText: `${originalMessage}\n用户选择报销场景:${expenseTypeLabel}`,
|
||
userText: `选择${expenseTypeLabel}`,
|
||
pendingText: `已选择${expenseTypeLabel},正在按该场景识别...`,
|
||
systemGenerated: true,
|
||
extraContext: {
|
||
draft_claim_id: '',
|
||
user_input_text: originalMessage,
|
||
expense_scene_selection: {
|
||
expense_type: expenseType,
|
||
expense_type_label: expenseTypeLabel,
|
||
original_message: originalMessage
|
||
},
|
||
review_form_values: {
|
||
expense_type: expenseTypeLabel
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
return {
|
||
continueStewardApplicationFieldCompletion,
|
||
handleSuggestedAction,
|
||
isSuggestedActionSelected,
|
||
runShortcut
|
||
}
|
||
}
|