Files
X-Financial/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js

512 lines
19 KiB
JavaScript
Raw Normal View History

import {
buildApplicationPreviewFooterMessage,
buildApplicationPreviewRows,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
buildAiApplicationPrecheck,
buildAiApplicationSubmitConflictMessage,
isAiApplicationPrecheckBlocking
} from '../../utils/aiApplicationPrecheckModel.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
runAiApplicationPreviewAction
} from '../../services/aiApplicationPreviewActions.js'
import {
buildFailedInlineApplicationSubmitThinkingEvents,
buildInitialInlineApplicationSubmitThinkingEvents,
buildInlineApplicationDetailAction,
buildInlineApplicationPreview,
buildInlineApplicationPreviewActionResultText,
buildInlineApplicationSubmitPrecheckPayload,
buildInlineApplicationSubmitThinkingEvents,
completeInlineThinkingEvents,
extractInlineApplicationDraftPayload,
resolveInlineApplicationPreviewActionFromText
} from './workbenchAiApplicationPreviewModel.js'
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
const fields = normalizeApplicationPreview(applicationPreview).fields || {}
return [
fields.transportPolicy,
fields.policyEstimate,
fields.transportEstimatedAmount,
fields.amount
].some((value) => /正在|查询中/.test(String(value || '')))
}
function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim())
}
export function useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation,
applicationPreviewEditor,
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft,
cancelApplicationPreviewEditor,
clearAiModeFiles,
closeWorkbenchDatePicker,
commitApplicationPreviewEditor: commitBaseApplicationPreviewEditor,
conversationId,
conversationMessages,
conversationStarted,
createInlineMessage,
currentUser,
handleApplicationPreviewEditorKeydown,
inlineConversationAutoScrollPinned,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
persistCurrentConversation,
pushInlineApplicationActionUserMessage,
pushInlineUserMessage,
refreshApplicationPreviewEstimate,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveInlineThinkingEvents,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
sending,
toast
}) {
function isApplicationPreviewEstimatePending(message = {}) {
return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
}
function canShowInlineSuggestedActions(message = {}) {
return Boolean(message?.suggestedActions?.length) && !isApplicationPreviewEstimatePending(message)
}
function isInlineSuggestedActionDisabled(action = {}, message = {}) {
const actionType = String(action?.action_type || '').trim()
return (
Boolean(action?.disabled) ||
(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION && sending.value) ||
(
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
isApplicationPreviewEstimatePending(message)
)
)
}
function resolveInlineApplicationPreviewRows(message) {
return buildApplicationPreviewRows(message?.applicationPreview || {})
}
function resolveInlineApplicationPreviewMissingFields(message) {
return normalizeApplicationPreview(message?.applicationPreview || {}).missingFields || []
}
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
const control = resolveApplicationPreviewEditorControl(fieldKey)
return control === 'date' ? 'text' : control
}
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
if (isApplicationPreviewEstimatePendingPreview(applicationPreview)) {
return []
}
const normalized = normalizeApplicationPreview(applicationPreview)
const actions = [{
label: '保存草稿',
description: '先保存当前申请表,后续可以继续补充或提交。',
icon: 'mdi mdi-content-save-outline',
action_type: AI_APPLICATION_ACTION_SAVE_DRAFT,
payload: { draftPayload }
}]
if (normalized.readyToSubmit) {
actions.push({
label: '直接提交',
description: '提交前先核查相同日期申请单,确认通过后进入审批流程。',
icon: 'mdi mdi-send-check-outline',
action_type: AI_APPLICATION_ACTION_SUBMIT,
payload: { draftPayload }
})
}
return actions
}
function syncInlineApplicationPreviewMessageContent(message) {
if (!message?.applicationPreview) {
return
}
const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview)
message.content = nextContent
message.text = nextContent
message.suggestedActions = buildInlineApplicationPreviewSuggestedActions(message.applicationPreview, message.draftPayload)
}
async function commitInlineApplicationPreviewEditor(message) {
const shouldLockForEstimate = shouldRefreshInlineApplicationPreviewEstimate(applicationPreviewEditor.value.fieldKey)
if (shouldLockForEstimate) {
message.suggestedActions = []
persistCurrentConversation()
}
const committed = await commitBaseApplicationPreviewEditor(message)
syncInlineApplicationPreviewMessageContent(message)
persistCurrentConversation()
return committed
}
function handleInlineApplicationPreviewEditorKeydown(event, message) {
if (event.key === 'Enter') {
event.preventDefault()
void commitInlineApplicationPreviewEditor(message)
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelApplicationPreviewEditor()
return
}
handleApplicationPreviewEditorKeydown(event, message)
}
function buildInlineApplicationPreviewFooterText(message) {
const normalized = normalizeApplicationPreview(message?.applicationPreview || {})
if (isApplicationPreviewEstimatePending(message)) {
return '费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。'
}
if (normalized.validationIssues?.length || normalized.missingFields?.length) {
return buildApplicationPreviewFooterMessage(normalized)
}
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
}
function resolveLatestApplicationPreviewMessage() {
return [...conversationMessages.value]
.reverse()
.find((message) => message.role === 'assistant' && message.applicationPreview)
}
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
applicationSubmitConfirmContext.value = {
messageId: String(targetMessage?.id || '').trim(),
draftPayload: targetMessage?.draftPayload || options.draftPayload || null,
userText: String(options.userText || '直接提交').trim() || '直接提交'
}
applicationSubmitConfirmOpen.value = true
persistCurrentConversation()
}
function cancelInlineApplicationSubmitConfirm() {
applicationSubmitConfirmOpen.value = false
applicationSubmitConfirmContext.value = null
}
function confirmInlineApplicationSubmit() {
const context = applicationSubmitConfirmContext.value || {}
applicationSubmitConfirmOpen.value = false
applicationSubmitConfirmContext.value = null
const sourceMessage = conversationMessages.value.find((message) => message.id === context.messageId)
if (!sourceMessage?.applicationPreview) {
toast('当前申请表已变化,请重新点击直接提交。')
return
}
void executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, sourceMessage, {
confirmed: true,
skipUserMessage: false,
draftPayload: context.draftPayload || null,
userText: context.userText || '直接提交'
})
}
async function runInlineApplicationSubmitPrecheck(targetMessage, pendingMessage, normalizedPreview, options = {}) {
try {
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
const precheck = buildAiApplicationPrecheck(normalizedPreview, {
claimsPayload: buildInlineApplicationSubmitPrecheckPayload(
claimsPayload,
targetMessage.draftPayload || options.draftPayload || null
),
currentUser: currentUser.value || {},
expenseType: 'travel'
})
const thinkingEvents = buildInlineApplicationSubmitThinkingEvents(precheck)
const blocked = isAiApplicationPrecheckBlocking(precheck)
if (blocked) {
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', buildAiApplicationSubmitConflictMessage(normalizedPreview, precheck), {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents
}
})
)
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
return false
}
const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
message.content = '提交前核查通过,正在提交申请并进入审批流程...'
message.paragraphs = ['提交前核查通过,正在提交申请并进入审批流程...']
message.stewardPlan = {
...(message.stewardPlan || {}),
streamStatus: 'streaming',
thinkingEvents
}
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
return true
} catch (error) {
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', [
'### 提交前核查失败',
'系统未能完成相同日期申请单查询,所以本次申请没有提交。',
'请稍后重试;如果仍然失败,请先到单据中心核对是否已有同日期申请单。'
].join('\n\n'), {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildFailedInlineApplicationSubmitThinkingEvents(error)
}
})
)
toast('提交前核查失败,已暂停提交。')
persistCurrentConversation()
return false
}
}
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
if (!targetMessage?.applicationPreview) {
toast('当前没有可提交的申请表。')
return false
}
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim()
if (isSubmit && !normalizedPreview.readyToSubmit) {
if (!options.skipUserMessage) {
pushInlineApplicationActionUserMessage(userText)
}
const missingText = normalizedPreview.missingFields?.length
? `当前还缺少:${normalizedPreview.missingFields.join('、')}`
: ''
const validationText = normalizedPreview.validationIssues?.length
? normalizedPreview.validationIssues.map((item) => item.message).join('')
: ''
conversationMessages.value.push(createInlineMessage('assistant', [
'### 暂不能提交申请',
missingText || validationText || '当前申请表还未通过提交校验。',
'请先点击表格中的字段补充或修正,补齐后我会开放“直接提交”入口。'
].filter(Boolean).join('\n\n')))
persistCurrentConversation()
scrollInlineConversationToBottom()
return true
}
if (isSubmit && !options.confirmed) {
requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
return true
}
if (!options.skipUserMessage) {
pushInlineApplicationActionUserMessage(userText)
}
sending.value = true
const pendingMessage = createInlineMessage(
'assistant',
isSubmit ? '正在提交前核查相同日期申请单...' : '正在保存申请草稿...',
{
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: isSubmit
? buildInitialInlineApplicationSubmitThinkingEvents()
: [
{
eventId: 'application-save-draft',
title: '保存申请草稿',
content: '正在按当前申请表内容保存草稿。',
status: 'running'
}
]
}
}
)
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
if (isSubmit) {
const precheckPassed = await runInlineApplicationSubmitPrecheck(
targetMessage,
pendingMessage,
normalizedPreview,
options
)
if (!precheckPassed) {
return true
}
}
const payload = await runAiApplicationPreviewAction({
actionType,
applicationPreview: normalizedPreview,
currentUser: currentUser.value || {},
conversationId: conversationId.value,
draftPayload: targetMessage.draftPayload || options.draftPayload || null
})
const draftPayload = extractInlineApplicationDraftPayload(payload)
if (draftPayload) {
targetMessage.draftPayload = draftPayload
}
targetMessage.suggestedActions = []
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
},
suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : []
})
)
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
return true
} catch (error) {
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
...item,
status: 'failed'
}))
}
})
)
toast(error?.message || (isSubmit ? '申请提交失败。' : '申请草稿保存失败。'))
persistCurrentConversation()
return true
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
}
function handleInlineApplicationPreviewTextAction(prompt, applicationPreviewEstimatePending) {
if (applicationPreviewEstimatePending.value) {
toast('请等待费用测算完成后再继续操作。')
return true
}
const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
if (!actionType || !resolveLatestApplicationPreviewMessage()) {
return false
}
void executeInlineApplicationPreviewAction(actionType, null, { userText: prompt })
return true
}
async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceText = '', options = {}) {
if (!conversationStarted.value) {
activateInlineConversation({ title: String(expenseTypeLabel || expenseType || '申请').trim().slice(0, 18) || '申请' })
}
const previewSourceText = String(sourceText || resolveLatestInlineUserPrompt()).trim()
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
clearAiModeFiles()
if (options.pushUserMessage !== false) {
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
}
const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: [
{
eventId: 'application-preview-build',
title: '整理申请表字段',
content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。',
status: 'running'
},
{
eventId: 'application-preview-estimate',
title: '同步费用测算',
content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
status: 'pending'
}
]
}
})
conversationMessages.value.push(pendingMessage)
persistCurrentConversation()
scrollInlineConversationToBottom()
try {
const preview = await refreshApplicationPreviewEstimate(
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {})
)
const content = buildLocalApplicationPreviewMessage(preview)
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, {
id: pendingMessage.id,
applicationPreview: preview,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
},
text: content
}))
} catch (error) {
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
...item,
status: 'failed'
}))
}
}))
toast(error?.message || '申请核对表生成失败。')
} finally {
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
}
return {
buildInlineApplicationPreviewFooterText,
buildInlineApplicationPreviewSuggestedActions,
canShowInlineSuggestedActions,
cancelInlineApplicationSubmitConfirm,
commitInlineApplicationPreviewEditor,
confirmInlineApplicationSubmit,
executeInlineApplicationPreviewAction,
handleInlineApplicationPreviewEditorKeydown,
handleInlineApplicationPreviewTextAction,
isApplicationPreviewEditing,
isApplicationPreviewEstimatePending,
isInlineSuggestedActionDisabled,
openApplicationPreviewEditor,
resolveApplicationPreviewEditorOptions,
resolveInlineApplicationPreviewEditorControl,
resolveInlineApplicationPreviewMissingFields,
resolveInlineApplicationPreviewRows,
startAiApplicationPreview
}
}