Files
X-Financial/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js
caoxiaozhu 59353308a2 feat(web): AI 意图规划置信度阈值与动作策略细化
- workbenchAiIntentPlannerModel 新增 WORKBENCH_AI_INTENT_CONFIDENCE_THRESHOLD 与 isLowConfidenceTravelApplicationPlan,shouldRequestWorkbenchAiIntentPlan 增加业务关键词前置过滤
- resolveExecutableTravelApplicationPlan 区分 requestedSubmit 与提交确认(submitRequiresConfirmation),autoSubmit 不再直接置真
- workbenchIntentActionPolicy 改用 policyDecision 路由(need_confirmation/query_candidates),透传 riskLevel/requiresSelection/requiresConfirmation
- workbenchIntentFrameModel 补充 query 动作识别,usePersonalWorkbenchAiMode/useWorkbenchAiActionRouter/useWorkbenchAiApplicationPreviewFlow 接入低置信度与确认流程
- 更新 intent-planner-model/intent-frame-model/application-gate-model/fast-preview 测试
2026-06-25 10:55:49 +08:00

609 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
} from './workbenchAiApplicationPreviewModel.js'
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
import {
completeWorkbenchAiThinkingEvents,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.js'
import {
isOrphanInlineApplicationPreviewMessage,
resolveInlineApplicationPreviewTextAction,
resolveLatestApplicationPreviewMessage,
resolveLatestOrphanApplicationPreviewMessage
} from './workbenchAiApplicationGateModel.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', 'time_return', '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,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
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) {
return resolveApplicationPreviewEditorControl(fieldKey)
}
function resolveInlineApplicationPreviewEditorDateMin(message, fieldKey) {
return resolveApplicationPreviewEditorDateMin?.(message, fieldKey) || ''
}
function resolveInlineApplicationPreviewEditorDateMax(message, fieldKey) {
return resolveApplicationPreviewEditorDateMax?.(message, fieldKey) || ''
}
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)
}
if (message?.submitRequiresConfirmation) {
return '已识别到您希望直接提交。系统不会自动提交申请,请先核对申请核对表;确认无误后,点击下方“直接提交”按钮再进入提交确认。'
}
return '申请核对表已补齐,费用测算已同步。您仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
}
function buildInlineApplicationActionFailureText(error, isSubmit) {
return [
isSubmit ? '### 申请提交失败' : '### 申请草稿保存失败',
error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'),
'我已保留当前申请核对表,您可以修改后重试,也可以稍后再次保存。'
].join('\n\n')
}
function hasSavedInlineApplicationDraft(message = {}, options = {}) {
const draftPayload = message?.draftPayload || options.draftPayload || null
if (!draftPayload || typeof draftPayload !== 'object') {
return false
}
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
const status = String(draftPayload.status || '').trim().toLowerCase()
return Boolean((claimId || claimNo) && status !== 'submitted')
}
function isContextualInlineApplicationSubmitText(text = '') {
const normalized = String(text || '').replace(/\s+/g, '').trim()
return Boolean(
normalized &&
/提交/.test(normalized) &&
/(这个|这张|当前|上面|上述|刚才|申请单|单据|草稿|领导|审核|审批)/.test(normalized)
)
}
function resolveLatestInlineApplicationPreviewMessage() {
return resolveLatestApplicationPreviewMessage(conversationMessages.value)
}
function resolveLatestOrphanInlineApplicationPreviewMessage() {
return resolveLatestOrphanApplicationPreviewMessage(conversationMessages.value)
}
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 : resolveLatestInlineApplicationPreviewMessage()
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
}
const shouldSubmitSavedDraftDirectly = isSubmit &&
!options.confirmed &&
hasSavedInlineApplicationDraft(targetMessage, options) &&
isContextualInlineApplicationSubmitText(userText)
if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly) {
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: buildInlineApplicationDetailAction(draftPayload)
})
)
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
return true
} catch (error) {
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', buildInlineApplicationActionFailureText(error, isSubmit), {
id: pendingMessage.id,
applicationPreview: targetMessage.applicationPreview,
draftPayload: targetMessage.draftPayload || options.draftPayload || null,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(
targetMessage.applicationPreview,
targetMessage.draftPayload || options.draftPayload || null
),
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 = resolveInlineApplicationPreviewTextAction(prompt)
if (!actionType) {
return false
}
if (!resolveLatestInlineApplicationPreviewMessage()) {
const orphanPreviewMessage = resolveLatestOrphanInlineApplicationPreviewMessage()
if (!orphanPreviewMessage) {
return false
}
const previewSourceText = resolveLatestInlineUserPrompt()
pushInlineApplicationActionUserMessage(prompt)
toast('当前申请核对表状态不完整,我先重新生成可编辑表格。')
void startAiApplicationPreview('travel', '差旅费', previewSourceText, {
pushUserMessage: false
})
return true
}
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 existingPendingMessage = options.pendingMessageId
? conversationMessages.value.find((item) => item.id === options.pendingMessageId)
: null
const previousThinkingEvents = completeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(existingPendingMessage))
const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
id: options.pendingMessageId,
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: mergeWorkbenchAiThinkingEvents(previousThinkingEvents, [
{
eventId: 'application-preview-build',
title: '整理申请表字段',
content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。',
status: 'running'
},
{
eventId: 'application-preview-estimate',
title: '同步费用测算',
content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
status: 'pending'
}
])
}
})
if (options.pendingMessageId) {
replaceInlineMessage(options.pendingMessageId, pendingMessage)
} else {
conversationMessages.value.push(pendingMessage)
}
persistCurrentConversation()
scrollInlineConversationToBottom()
try {
const preview = await refreshApplicationPreviewEstimate(
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {}, {
ontologyFields: options.ontologyFields
})
)
const content = buildLocalApplicationPreviewMessage(preview)
const previewMessage = createInlineMessage('assistant', content, {
id: pendingMessage.id,
applicationPreview: preview,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
requestedSubmit: Boolean(options.requestedSubmit),
submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation),
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
},
text: content
})
replaceInlineMessage(pendingMessage.id, previewMessage)
if (options.autoSaveDraft) {
await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, {
skipUserMessage: true,
userText: options.userMessage || '保存草稿'
})
}
} 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,
resolveInlineApplicationPreviewEditorDateMax,
resolveInlineApplicationPreviewEditorDateMin,
resolveInlineApplicationPreviewMissingFields,
resolveInlineApplicationPreviewRows,
startAiApplicationPreview
}
}