refactor: enforce 800 line source limits

This commit is contained in:
caoxiaozhu
2026-06-22 11:58:53 +08:00
parent 08a4fa3577
commit 6d33ba5742
150 changed files with 27413 additions and 23791 deletions

View File

@@ -0,0 +1,787 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSystemState } from '../useSystemState.js'
import { useToast } from '../useToast.js'
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
import { fetchSettings } from '../../services/settings.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
import {
deleteAiWorkbenchConversation,
loadAiWorkbenchConversationHistory,
saveAiWorkbenchConversation
} from '../../utils/aiWorkbenchConversationStore.js'
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
import {
buildAiDocumentDetailRequest,
parseAiApplicationDetailHref,
parseAiDocumentDetailHref
} from '../../utils/aiDocumentDetailReference.js'
import {
AI_MODE_ACTION_ITEMS,
buildSelectedFileCards,
shouldRunAiAttachmentAutoAssociation
} from './workbenchAiComposerModel.js'
import {
createWorkbenchAiMessageRuntime,
formatMessageTime,
normalizeInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicationPreviewFlow.js'
import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
const INLINE_ANSWER_STREAM_DELAY_MS = 24
const INLINE_AUTO_SCROLL_THRESHOLD = 96
const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260
export function usePersonalWorkbenchAiMode(props, emit) {
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
const assistantInputRef = ref(null)
const fileInputRef = ref(null)
const conversationScrollRef = ref(null)
const inlineConversationAutoScrollPinned = ref(true)
const selectedFiles = ref([])
const systemSettings = ref(null)
const conversationStarted = ref(false)
const conversationMessages = ref([])
const conversationId = ref('')
const activeConversationTitle = ref('')
const sending = ref(false)
const stewardState = ref(null)
const aiExpenseDraft = ref(null)
const thinkingExpandedMessageIds = ref(new Set())
const thinkingCollapsedMessageIds = ref(new Set())
const attachmentOcrExpandedMessageIds = ref(new Set())
const deleteDialogOpen = ref(false)
const applicationSubmitConfirmOpen = ref(false)
const applicationSubmitConfirmContext = ref(null)
const aiAttachmentAssociationRuntime = new Map()
const messageRuntime = createWorkbenchAiMessageRuntime()
const {
createAiAttachmentAssociationId,
createInlineMessage,
normalizeRuntimeMessage,
serializeRuntimeMessage
} = messageRuntime
const {
applicationPreviewEditor,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
refreshApplicationPreviewEstimate,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
cancelApplicationPreviewEditor,
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState: () => persistCurrentConversation(),
toast,
calculateTravelReimbursement,
currentUser
})
const {
workbenchDatePickerOpen,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
applyWorkbenchDateSelection,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
const aiModeActionItems = AI_MODE_ACTION_ITEMS
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
const displayModelName = computed(() => {
const llmForm = systemSettings.value?.llmForm
if (!llmForm) return 'Axiom Ultra 3.1'
const model = llmForm.mainModel || ''
const provider = llmForm.mainProvider || ''
if (!model) return 'Axiom Ultra 3.1'
return provider ? `${provider} / ${model}` : model
})
const modelSelectorTitle = computed(() => {
const llmForm = systemSettings.value?.llmForm
if (!llmForm) return '当前模型Axiom Ultra 3.1'
const model = llmForm.mainModel || 'Axiom Ultra 3.1'
const provider = llmForm.mainProvider || ''
return provider ? `当前模型:${provider} / ${model}` : `当前模型:${model}`
})
const filesFlow = useWorkbenchAiComposerFiles({
fileInputRef,
focusAiModeInput,
isInputLocked: () => isAiModeInputLocked.value,
selectedFiles,
toast
})
const documentQueryFlow = useWorkbenchAiDocumentQueryFlow({
conversationMessages,
createInlineMessage,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
scrollInlineConversationToBottom
})
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationMessages,
createAiAttachmentAssociationId,
createInlineMessage,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
toast
})
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation,
applicationPreviewEditor,
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft,
cancelApplicationPreviewEditor,
clearAiModeFiles: filesFlow.clearAiModeFiles,
closeWorkbenchDatePicker,
commitApplicationPreviewEditor,
conversationId,
conversationMessages,
conversationStarted,
createInlineMessage,
currentUser,
handleApplicationPreviewEditorKeydown,
inlineConversationAutoScrollPinned,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
persistCurrentConversation,
pushInlineApplicationActionUserMessage,
pushInlineUserMessage,
refreshApplicationPreviewEstimate,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveInlineThinkingEvents,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
sending,
toast
})
const expenseFlow = useWorkbenchAiExpenseFlow({
activateInlineConversation,
aiExpenseDraft,
assistantDraft,
clearAiModeFiles: filesFlow.clearAiModeFiles,
closeWorkbenchDatePicker,
conversationMessages,
conversationStarted,
createInlineMessage,
currentUser,
persistCurrentConversation,
pushInlineUserMessage,
removeWorkbenchDateTag,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
startAiApplicationPreview: applicationFlow.startAiApplicationPreview
})
const actionRouter = useWorkbenchAiActionRouter({
aiExpenseDraft,
applicationFlow,
assistantDraft,
attachmentFlow,
emit,
expenseFlow,
focusAiModeInput,
hasInlineAttachmentOcrDetails,
resolveLatestInlineUserPrompt,
selectedFiles,
startInlineConversation,
toast,
toggleInlineAttachmentOcrDetails
})
const sessionCommands = useWorkbenchAiSessionCommands({
activeConversationTitle,
attachmentOcrExpandedMessageIds,
conversationId,
conversationMessages,
conversationStarted,
createInlineMessage,
currentUser,
deleteAiWorkbenchConversation,
emit,
focusAiModeInput,
inlineConversationAutoScrollPinned,
normalizeRuntimeMessage,
refreshConversationHistory,
resetInlineConversationState,
scrollInlineConversationToBottom,
stewardState,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds,
toast
})
const messageActions = useWorkbenchAiMessageActions({
assistantDraft,
focusAiModeInput,
persistCurrentConversation,
toast
})
const stewardFlow = useWorkbenchAiStewardFlow({
activeConversationTitle,
collectAiModeReceiptContext: attachmentFlow.collectAiModeReceiptContext,
conversationId,
conversationMessages,
createInlineMessage,
currentUser,
deleteAiWorkbenchConversation,
emit,
handleAiDocumentQueryIntent: documentQueryFlow.handleAiDocumentQueryIntent,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
resolveInlineThinkingEvents,
scrollInlineConversationToBottom,
sending,
stewardState,
streamInlineAssistantContent,
updateInlineMessageContent,
appendInlineMessageContent,
toast
})
const applicationPreviewEstimatePending = computed(() => (
conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
))
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value)
const canSubmitAiModePrompt = computed(() => (
!isAiModeInputLocked.value && (
Boolean(assistantDraft.value.trim()) ||
selectedFiles.value.length > 0 ||
Boolean(workbenchDateTagLabel.value)
)
))
async function loadSystemSettings() {
try {
systemSettings.value = await fetchSettings()
} catch {
systemSettings.value = { llmForm: {} }
}
}
function focusAiModeInput() {
nextTick(() => {
assistantInputRef.value?.focus()
})
}
function isInlineConversationNearBottom() {
const el = conversationScrollRef.value
if (!el) {
return true
}
return el.scrollHeight - el.clientHeight - el.scrollTop <= INLINE_AUTO_SCROLL_THRESHOLD
}
function handleInlineConversationScroll() {
inlineConversationAutoScrollPinned.value = isInlineConversationNearBottom()
}
function forceInlineConversationToBottom() {
const el = conversationScrollRef.value
if (el) {
el.scrollTop = el.scrollHeight
inlineConversationAutoScrollPinned.value = true
}
}
function scrollInlineConversationToBottom(options = {}) {
const shouldScroll = options.force !== false
nextTick(() => {
if (!shouldScroll) {
return
}
forceInlineConversationToBottom()
window.requestAnimationFrame(() => {
forceInlineConversationToBottom()
})
window.setTimeout(() => {
if (inlineConversationAutoScrollPinned.value) {
forceInlineConversationToBottom()
}
}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS)
})
}
function scrollInlineConversationToTop() {
nextTick(() => {
const el = conversationScrollRef.value
if (el) {
inlineConversationAutoScrollPinned.value = false
el.scrollTo({ top: 0, behavior: 'smooth' })
}
})
}
function updateInlineMessageContent(message, content) {
if (!message) {
return
}
message.content = String(content || '')
message.paragraphs = String(message.content || '')
.split(/\n{2,}|\n/)
.map((item) => item.trim())
.filter(Boolean)
}
function appendInlineMessageContent(message, delta) {
const nextDelta = String(delta || '')
if (!nextDelta) {
return
}
updateInlineMessageContent(message, `${message.content || ''}${nextDelta}`)
}
function waitInlineAnswerStreamFrame() {
return new Promise((resolve) => {
window.setTimeout(resolve, INLINE_ANSWER_STREAM_DELAY_MS)
})
}
async function streamInlineAssistantContent(messageId, content) {
const targetContent = String(content || '').trim()
let streamedContent = ''
for (let index = 0; index < targetContent.length; index += INLINE_ANSWER_STREAM_CHUNK_SIZE) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message || !message.pending) {
return
}
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
streamedContent += targetContent.slice(index, index + INLINE_ANSWER_STREAM_CHUNK_SIZE)
updateInlineMessageContent(message, streamedContent)
scrollInlineConversationToBottom({ force: shouldAutoScroll })
await waitInlineAnswerStreamFrame()
}
}
async function streamOrSetInlineAssistantContent(messageId, content) {
const targetContent = String(content || '').trim()
if (/<!--\s*ai-trusted-html:start\s*-->/.test(targetContent)) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (message?.pending) {
updateInlineMessageContent(message, targetContent)
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
return
}
await streamInlineAssistantContent(messageId, targetContent)
}
function refreshConversationHistory() {
const history = loadAiWorkbenchConversationHistory(currentUser.value || {})
emit('conversation-history-change', history)
return history
}
function isPersistableInlineConversation() {
return Boolean(
conversationId.value &&
conversationId.value !== AI_SEARCH_CONVERSATION_ID &&
conversationMessages.value.length
)
}
function persistCurrentConversation() {
if (!isPersistableInlineConversation()) {
refreshConversationHistory()
return []
}
const history = saveAiWorkbenchConversation(currentUser.value || {}, {
id: conversationId.value,
conversationId: conversationId.value,
title: activeConversationTitle.value,
source: 'workbench',
sessionType: 'steward',
stewardState: stewardState.value,
messages: conversationMessages.value.map((message) => serializeRuntimeMessage(message))
})
emit('conversation-history-change', history)
return history
}
function resetInlineConversationState() {
conversationStarted.value = false
conversationMessages.value = []
conversationId.value = ''
stewardState.value = null
activeConversationTitle.value = ''
assistantDraft.value = ''
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
attachmentOcrExpandedMessageIds.value = new Set()
deleteDialogOpen.value = false
applicationSubmitConfirmOpen.value = false
applicationSubmitConfirmContext.value = null
clearWorkbenchDateSelection()
filesFlow.clearAiModeFiles()
}
function replaceInlineMessage(id, nextMessage) {
const index = conversationMessages.value.findIndex((item) => item.id === id)
if (index === -1) {
conversationMessages.value.push(nextMessage)
return
}
conversationMessages.value.splice(index, 1, nextMessage)
}
function activateInlineConversation(options = {}) {
conversationStarted.value = true
if (!conversationId.value) {
conversationId.value = options.id || `inline-${Date.now()}`
}
activeConversationTitle.value = options.title || activeConversationTitle.value || '新对话'
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
}
function renderInlineConversationHtml(content) {
return renderAiConversationHtml(content)
}
function resolveInlineThinkingEvents(message) {
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
}
function hasInlineThinking(message) {
return resolveInlineThinkingEvents(message).length > 0
}
function isInlineThinkingExpanded(message) {
if (!message?.id) {
return Boolean(message?.pending)
}
if (thinkingCollapsedMessageIds.value.has(message.id)) {
return false
}
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
}
function toggleInlineThinking(message) {
if (!message?.id) {
return
}
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
if (isInlineThinkingExpanded(message)) {
nextExpandedIds.delete(message.id)
nextCollapsedIds.add(message.id)
} else {
nextCollapsedIds.delete(message.id)
nextExpandedIds.add(message.id)
}
thinkingExpandedMessageIds.value = nextExpandedIds
thinkingCollapsedMessageIds.value = nextCollapsedIds
}
function hasInlineAttachmentOcrDetails(message = {}) {
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
return Boolean(details?.documents?.length || details?.fileNames?.length)
}
function resolveInlineAttachmentOcrDocuments(message = {}) {
return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
}
function resolveInlineAttachmentOcrFileCount(message = {}) {
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
}
function isInlineAttachmentOcrExpanded(message = {}) {
return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
}
function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
return
}
const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
const shouldExpand = forceExpanded === null
? !nextExpandedIds.has(message.id)
: Boolean(forceExpanded)
if (shouldExpand) {
nextExpandedIds.add(message.id)
} else {
nextExpandedIds.delete(message.id)
}
attachmentOcrExpandedMessageIds.value = nextExpandedIds
nextTick(() => {
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
})
}
function buildInlinePromptText(rawPrompt, files = []) {
const prompt = buildWorkbenchPromptText(rawPrompt)
if (prompt) {
return prompt
}
return files.length ? '请帮我处理已上传的附件。' : ''
}
function handleAiAnswerMarkdownClick(event) {
const target = event?.target
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
if (!link) {
return
}
const href = link.getAttribute('href')
const detailReference = parseAiDocumentDetailHref(href) || parseAiApplicationDetailHref(href)
if (!detailReference) {
return
}
event.preventDefault()
event.stopPropagation()
emit('open-document', buildAiDocumentDetailRequest(detailReference))
}
function startInlineConversation(prompt, entry = {}, files = []) {
if (isAiModeInputLocked.value) {
toast('请等待费用测算完成后再继续操作。')
return
}
const cleanPrompt = buildInlinePromptText(prompt, files)
if (!cleanPrompt || sending.value) {
return
}
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
return
}
if (applicationFlow.handleInlineApplicationPreviewTextAction(cleanPrompt, applicationPreviewEstimatePending)) {
return
}
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
conversationId.value = ''
conversationMessages.value = []
activeConversationTitle.value = ''
}
sending.value = true
activateInlineConversation({
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
})
inlineConversationAutoScrollPinned.value = true
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
filesFlow.clearAiModeFiles()
scrollInlineConversationToBottom()
persistCurrentConversation()
if (shouldRunAiAttachmentAutoAssociation(entry, files, cleanPrompt)) {
void attachmentFlow.requestAiAttachmentAssociationReply(cleanPrompt, entry, files)
return
}
void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files)
}
function submitAiModePrompt() {
if (!canSubmitAiModePrompt.value) {
toast('请输入需求后再发送。')
focusAiModeInput()
return
}
startInlineConversation(assistantDraft.value, { source: 'workbench', sessionType: 'steward' }, Array.from(selectedFiles.value))
}
function runAiModeAction(item) {
if (String(item?.label || '').trim() === '发起报销') {
expenseFlow.pushInlineExpenseSceneSelectionPrompt(item.prompt, item.label)
return
}
startInlineConversation(item.prompt, item, Array.from(selectedFiles.value))
}
function regenerateLastReply() {
const lastUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
if (!lastUserMessage || sending.value) {
return
}
const lastAssistantIndex = conversationMessages.value.map((message) => message.role).lastIndexOf('assistant')
if (lastAssistantIndex >= 0) {
conversationMessages.value.splice(lastAssistantIndex, 1)
}
sending.value = true
void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
}
function pushInlineUserMessage(text) {
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
}
function pushInlineApplicationActionUserMessage(text) {
pushInlineUserMessage(text)
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
filesFlow.clearAiModeFiles()
}
function resolveLatestInlineUserPrompt() {
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
return String(latestUserMessage?.content || '').trim()
}
function handleVoiceInput() {
if (isAiModeInputLocked.value) {
toast('请等待费用测算完成后再继续操作。')
return
}
toast('语音输入正在准备中,您可以先输入文字需求。')
focusAiModeInput()
}
watch(
() => props.sidebarCommand?.seq,
() => {
const command = props.sidebarCommand || {}
if (command.type === 'new-chat') {
sessionCommands.startNewInlineConversation()
return
}
if (command.type === 'search-chat') {
sessionCommands.openInlineSearchConversation(activateInlineConversation)
return
}
if (command.type === 'open-recent') {
sessionCommands.openInlineRecentConversation(command.payload || {})
}
}
)
onMounted(() => {
loadSystemSettings()
refreshConversationHistory()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
})
return {
activeConversationTitle,
aiModeActionItems,
applicationPreviewEditor,
applicationSubmitConfirmOpen,
assistantInputRef,
assistantDraft,
canShowInlineSuggestedActions: applicationFlow.canShowInlineSuggestedActions,
canSubmitAiModePrompt,
cancelDeleteConversation: () => sessionCommands.cancelDeleteConversation(deleteDialogOpen),
cancelInlineApplicationSubmitConfirm: applicationFlow.cancelInlineApplicationSubmitConfirm,
clearWorkbenchDateSelection,
commitInlineApplicationPreviewEditor: applicationFlow.commitInlineApplicationPreviewEditor,
confirmDeleteConversation: () => sessionCommands.confirmDeleteConversation(deleteDialogOpen),
confirmInlineApplicationSubmit: applicationFlow.confirmInlineApplicationSubmit,
conversationMessages,
conversationScrollRef,
conversationStarted,
deleteDialogOpen,
displayModelName,
displayUserName,
fileInputRef,
handleAiAnswerMarkdownClick,
handleAiModeFilesChange: filesFlow.handleAiModeFilesChange,
handleInlineApplicationPreviewEditorKeydown: applicationFlow.handleInlineApplicationPreviewEditorKeydown,
handleInlineConversationScroll,
handleInlineSuggestedAction: actionRouter.handleInlineSuggestedAction,
handleVoiceInput,
hasInlineAttachmentOcrDetails,
hasInlineThinking,
isAiModeInputLocked,
isApplicationPreviewEditing: applicationFlow.isApplicationPreviewEditing,
isApplicationPreviewEstimatePending: applicationFlow.isApplicationPreviewEstimatePending,
isInlineAttachmentOcrExpanded,
isInlineSuggestedActionDisabled: applicationFlow.isInlineSuggestedActionDisabled,
isInlineThinkingExpanded,
markInlineMessageFeedback: messageActions.markInlineMessageFeedback,
modelSelectorTitle,
openApplicationPreviewEditor: applicationFlow.openApplicationPreviewEditor,
quoteInlineMessage: messageActions.quoteInlineMessage,
regenerateLastReply,
removeAiModeFile: filesFlow.removeAiModeFile,
removeWorkbenchDateTag,
renderInlineConversationHtml,
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
resolveInlineAttachmentOcrDocuments,
resolveInlineAttachmentOcrFileCount,
resolveInlineThinkingEvents,
runAiModeAction,
scrollInlineConversationToTop,
selectedFileCards,
sending,
setWorkbenchDateMode,
submitAiModePrompt,
toggleInlineAttachmentOcrDetails,
toggleInlineThinking,
toggleWorkbenchDatePicker,
triggerAiModeFileUpload: filesFlow.triggerAiModeFileUpload,
workbenchCanApplyDateSelection,
workbenchDateMode,
workbenchDatePickerOpen,
workbenchDateTagLabel,
workbenchRangeEndDate,
workbenchRangeStartDate,
workbenchSingleDate,
applyWorkbenchDateSelection,
buildInlineApplicationPreviewFooterText: applicationFlow.buildInlineApplicationPreviewFooterText,
copyInlineMessage: messageActions.copyInlineMessage,
formatMessageTime,
handleWorkbenchDateInputChange
}
}

View File

@@ -0,0 +1,125 @@
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
} from '../../services/aiApplicationPreviewActions.js'
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import {
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
AI_ATTACHMENT_OCR_DETAIL_ACTION
} from './workbenchAiMessageModel.js'
import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
export function useWorkbenchAiActionRouter({
aiExpenseDraft,
applicationFlow,
assistantDraft,
attachmentFlow,
emit,
expenseFlow,
focusAiModeInput,
hasInlineAttachmentOcrDetails,
resolveLatestInlineUserPrompt,
selectedFiles,
startInlineConversation,
toast,
toggleInlineAttachmentOcrDetails
}) {
function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
const prefillText = resolveSuggestedActionPrefill(action)
if (prefillText) {
assistantDraft.value = mergeComposerPrefill(assistantDraft.value, prefillText)
focusAiModeInput()
return
}
const actionType = String(action?.action_type || '').trim()
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
if (actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION) {
if (!hasInlineAttachmentOcrDetails(sourceMessage)) {
toast('当前消息没有可查看的附件识别明细。')
return
}
toggleInlineAttachmentOcrDetails(sourceMessage, true)
return
}
if (actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION) {
if (applicationFlow.isInlineSuggestedActionDisabled(action, sourceMessage)) {
return
}
void attachmentFlow.confirmAiAttachmentAssociation(actionPayload, sourceMessage)
return
}
if ([AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType)) {
if (applicationFlow.isInlineSuggestedActionDisabled(action, sourceMessage)) {
toast('请等待费用测算完成后再继续操作。')
return
}
void applicationFlow.executeInlineApplicationPreviewAction(actionType, sourceMessage, {
userText: action.label,
draftPayload: actionPayload.draftPayload || actionPayload.draft_payload || null
})
return
}
if (actionType === 'open_application_detail') {
const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
emit('open-document', buildAiDocumentDetailRequest({
reference: claimNo || claimId,
claimId,
claimNo
}))
return
}
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true)
return
}
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
aiExpenseDraft.value = null
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
return
}
if (actionType === 'select_expense_type') {
const expenseType = String(action?.payload?.expense_type || '').trim()
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
const requiresApplicationBeforeReimbursement = Boolean(action?.payload?.requires_application_before_reimbursement)
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement)
return
}
if (actionType === 'select_required_application') {
expenseFlow.linkAiExpenseApplication(action?.payload || {})
return
}
if (actionType === 'ai_application_start_inline') {
aiExpenseDraft.value = null
void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
return
}
const carryText = String(action?.payload?.carry_text || action?.label || '').trim()
if (!carryText) {
return
}
if (String(action?.payload?.session_type || '').trim() === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
expenseFlow.pushInlineExpenseSceneSelectionPrompt(carryText, action.label)
return
}
startInlineConversation(carryText, {
label: action.label,
source: 'steward-action',
sessionType: action?.payload?.session_type || 'steward'
}, action?.payload?.carry_files ? Array.from(selectedFiles.value) : [])
}
return {
handleInlineSuggestedAction
}
}

View File

@@ -0,0 +1,511 @@
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
}
}

View File

@@ -0,0 +1,357 @@
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import {
createExpenseClaimItem,
extractExpenseClaimItems,
fetchExpenseClaimDetail,
fetchExpenseClaims,
uploadExpenseClaimItemAttachment
} from '../../services/reimbursements.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import {
AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
const completed = status === 'completed'
const failed = status === 'failed'
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
return [
{
eventId: 'attachment-ocr',
title: '识别上传票据',
content: '提取票据里的日期、地点和行程信息。',
status: eventStatus
},
{
eventId: 'claim-lookup',
title: '查询可关联报销单',
content: '查找草稿、待补充和退回状态的可归集单据。',
status: eventStatus
},
{
eventId: 'claim-match',
title: '匹配票据与报销单',
content: '根据票据时间、城市和报销事由判断最可能的关联单据。',
status: eventStatus
}
]
}
function resolveAiAttachmentAssociationClaimNo(payload = {}) {
return String(payload?.claim_no || payload?.claimNo || '').trim()
}
function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
const completed = status === 'completed'
const failed = status === 'failed'
const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
return [
{
eventId: 'attachment-confirm',
title: '确认自动归集',
content: '正在读取匹配单据并准备写入附件。',
status: eventStatus
},
{
eventId: 'attachment-upload',
title: '归集票据附件',
content: '把本次上传的票据写入报销单明细。',
status: eventStatus
}
]
}
export function useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationMessages,
createAiAttachmentAssociationId,
createInlineMessage,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
toast
}) {
async function collectAiModeReceiptContext(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const attachmentNames = safeFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const ocrSourceFileNames = ocrFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const baseContext = {
attachmentNames,
attachmentCount: attachmentNames.length,
ocrSourceFileNames,
ocrSummary: '',
ocrDocuments: []
}
if (!ocrFiles.length) {
return baseContext
}
try {
const collected = await collectReceiptFiles({
files: ocrFiles,
recognizeOcrFiles
})
return {
...baseContext,
ocrSummary: String(collected.ocrSummary || '').trim(),
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
}
} catch (error) {
console.warn('AI mode OCR request failed:', error)
return {
...baseContext,
ocrError: error?.message || 'OCR识别失败已继续使用附件名称。'
}
}
}
function findAiAttachmentAssociationRuntime(options = {}) {
const normalizedAssociationId = String(options.associationId || options.association_id || '').trim()
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
if (normalizedAssociationId) {
const runtime = aiAttachmentAssociationRuntime.get(normalizedAssociationId)
if (runtime) {
return { associationId: normalizedAssociationId, runtime }
}
}
if (normalizedClaimNo) {
for (const [runtimeId, runtime] of aiAttachmentAssociationRuntime.entries()) {
if (String(runtime?.claimNo || '').trim() === normalizedClaimNo && runtime?.files?.length) {
return { associationId: runtimeId, runtime }
}
}
}
return { associationId: '', runtime: null }
}
function updateAiAttachmentAssociationActionState(message = {}, associationId = '', state = {}, options = {}) {
const normalizedAssociationId = String(associationId || '').trim()
const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
if (!message || !Array.isArray(message.suggestedActions) || (!normalizedAssociationId && !normalizedClaimNo)) {
return
}
message.suggestedActions = message.suggestedActions.map((action) => {
const actionAssociationId = String(action?.payload?.association_id || action?.payload?.associationId || '').trim()
const actionClaimNo = resolveAiAttachmentAssociationClaimNo(action?.payload || {})
const isSameAssociation = normalizedAssociationId && actionAssociationId === normalizedAssociationId
const isSameClaim = normalizedClaimNo && actionClaimNo === normalizedClaimNo
if (String(action?.action_type || '').trim() !== AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION || (!isSameAssociation && !isSameClaim)) {
return action
}
return {
...action,
...state
}
})
}
function buildAiAttachmentAssociationDetailActions(runtime = {}) {
const claimNo = String(runtime.claimNo || '').trim()
const claimId = String(runtime.claimId || '').trim()
if (!claimNo && !claimId) {
return []
}
return [{
label: '查看单据',
description: '打开已归集票据的报销单。',
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {
claim_id: claimId,
claim_no: claimNo,
document_type: 'expense'
}
}]
}
async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
const runtimeResult = findAiAttachmentAssociationRuntime({
associationId: requestedAssociationId,
claimNo: payloadClaimNo
})
const associationId = runtimeResult.associationId
const runtime = runtimeResult.runtime
const actionClaimNo = payloadClaimNo || String(runtime?.claimNo || '').trim()
if (!associationId || !runtime?.files?.length) {
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
return
}
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '正在归集...',
disabled: true
}, { claimNo: actionClaimNo })
persistCurrentConversation()
sending.value = true
const pendingMessage = createInlineMessage('assistant', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
const syncResult = await syncExpenseClaimFilesToDraft({
claimId: runtime.claimId,
files: runtime.files,
fetchExpenseClaimDetail,
createExpenseClaimItem,
uploadExpenseClaimItemAttachment
})
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: runtime.claimNo,
fileNames: runtime.fileNames,
uploadedCount: syncResult.uploadedCount,
skippedCount: syncResult.skippedCount
})
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('completed')
},
suggestedActions: buildAiAttachmentAssociationDetailActions(runtime)
})
)
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '已自动关联',
disabled: true
}, { claimNo: actionClaimNo })
aiAttachmentAssociationRuntime.delete(associationId)
persistCurrentConversation()
} catch (error) {
const finalMessageText = error?.message || '自动归集失败,请稍后重试。'
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('failed')
}
})
)
updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
label: '重新自动关联',
disabled: false
}, { claimNo: actionClaimNo })
toast(finalMessageText)
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
}
async function requestAiAttachmentAssociationReply(prompt, entry = {}, files = []) {
let shouldAutoScrollOnFinish = true
const pendingMessage = createInlineMessage('assistant', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('running')
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles
})
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
const claims = extractExpenseClaimItems(claimsPayload)
const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments)
const associationRecord = match.best?.record || match.recommended?.record || null
const associationId = associationRecord?.claimId
? createAiAttachmentAssociationId()
: ''
if (associationId) {
aiAttachmentAssociationRuntime.set(associationId, {
files,
fileNames: files.map((file) => file?.name || '').filter(Boolean),
claimId: String(associationRecord.claimId || '').trim(),
claimNo: String(associationRecord.claimNo || '').trim(),
ocrPayload: collected.ocrPayload,
ocrSummary: collected.ocrSummary,
ocrDocuments: collected.ocrDocuments,
ocrFilePreviews: collected.ocrFilePreviews
})
}
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
match,
fileNames: files.map((file) => file?.name || ''),
ocrDocuments: collected.ocrDocuments
})
await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
},
attachmentOcrDetails,
suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
includeOcrDetails: Boolean(attachmentOcrDetails)
})
})
)
persistCurrentConversation()
} catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildAiAttachmentAssociationThinkingEvents('failed')
}
})
)
toast(finalMessageText)
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
}
}
return {
collectAiModeReceiptContext,
confirmAiAttachmentAssociation,
requestAiAttachmentAssociationReply,
resolveAiAttachmentAssociationClaimNo
}
}

View File

@@ -0,0 +1,55 @@
import {
buildFileIdentity,
MAX_ATTACHMENTS,
mergeFilesWithLimit
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
export function useWorkbenchAiComposerFiles({
fileInputRef,
focusAiModeInput,
isInputLocked,
selectedFiles,
toast
}) {
function triggerAiModeFileUpload() {
if (isInputLocked()) {
toast('请等待费用测算完成后再继续操作。')
return
}
fileInputRef.value?.click()
}
function handleAiModeFilesChange(event) {
const fileMergeResult = mergeFilesWithLimit(selectedFiles.value, Array.from(event.target.files || []), MAX_ATTACHMENTS)
selectedFiles.value = fileMergeResult.files
if (fileMergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
focusAiModeInput()
}
function removeAiModeFile(fileKey) {
selectedFiles.value = selectedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey)
if (!selectedFiles.value.length && fileInputRef.value) {
fileInputRef.value.value = ''
}
focusAiModeInput()
}
function clearAiModeFiles() {
selectedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
return {
clearAiModeFiles,
handleAiModeFilesChange,
removeAiModeFile,
triggerAiModeFileUpload
}
}

View File

@@ -0,0 +1,204 @@
import { nextTick } from 'vue'
import {
buildAiDocumentQueryConditionSummary,
buildAiDocumentQueryMessage,
filterAiDocumentQueryRecords,
mergeAiDocumentQueryPayloads,
resolveAiDocumentQueryIntent
} from '../../utils/aiDocumentQueryModel.js'
import {
extractExpenseClaimItems,
fetchApprovalExpenseClaims,
fetchExpenseClaims
} from '../../services/reimbursements.js'
const AI_DOCUMENT_QUERY_STEP_DELAY_MS = 320
function waitForAiDocumentQueryStep() {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, AI_DOCUMENT_QUERY_STEP_DELAY_MS)
})
}
function completeAiDocumentQueryEvent(events, eventId, content = '') {
return events.map((event) => (
event.eventId === eventId
? {
...event,
content: content || event.content,
status: 'completed'
}
: event
))
}
function failAiDocumentQueryEvents(events) {
return events.map((event) => ({
...event,
status: event.status === 'completed' ? 'completed' : 'failed'
}))
}
function resolveAiDocumentQueryFetchPendingText(intent = {}) {
if (intent.source === 'approval') {
return '等待调用待我审核单据接口。'
}
if (intent.source === 'mine') {
return '等待调用我名下单据接口。'
}
return '等待同时调用我名下单据和待我审核单据接口。'
}
function resolveAiDocumentQueryFetchRunningText(intent = {}) {
if (intent.source === 'approval') {
return '正在查询待我审核的单据,接口范围为待办/待审单据列表。'
}
if (intent.source === 'mine') {
return '正在查询我名下的单据,接口范围为当前用户本人单据列表。'
}
return '正在查询我可见的单据,接口范围包含我名下单据和待我审核单据列表。'
}
async function fetchAiDocumentQueryPayload(intent = {}) {
const requestParams = { page: 1, pageSize: 100 }
if (intent.source === 'approval') {
return fetchApprovalExpenseClaims(requestParams)
}
if (intent.source === 'mine') {
return fetchExpenseClaims(requestParams)
}
const [ownPayload, approvalPayload] = await Promise.all([
fetchExpenseClaims(requestParams),
fetchApprovalExpenseClaims(requestParams)
])
return mergeAiDocumentQueryPayloads(
ownPayload,
{
items: extractExpenseClaimItems(approvalPayload),
querySource: 'approval'
}
)
}
export function useWorkbenchAiDocumentQueryFlow({
conversationMessages,
createInlineMessage,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
scrollInlineConversationToBottom
}) {
async function updateAiDocumentQueryThinking(pendingMessage, thinkingEvents, streamStatus = 'streaming') {
const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
message.stewardPlan = {
...(message.stewardPlan || {}),
streamStatus,
thinkingEvents
}
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
await nextTick()
}
async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
const intent = resolveAiDocumentQueryIntent(prompt)
if (!intent) {
return false
}
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
let thinkingEvents = [
{
eventId: 'document-query-parse',
title: '解析自然语言筛选条件',
content: `正在从你的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}`,
status: 'running'
},
{
eventId: 'document-query-fetch',
title: '查询业务单据接口',
content: resolveAiDocumentQueryFetchPendingText(intent),
status: 'pending'
},
{
eventId: 'document-query-filter',
title: '组合筛选单据',
content: '等待接口返回后,再按已识别条件做二次筛选。',
status: 'pending'
}
]
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
await waitForAiDocumentQueryStep()
thinkingEvents = completeAiDocumentQueryEvent(thinkingEvents, 'document-query-parse')
thinkingEvents = thinkingEvents.map((event) => (
event.eventId === 'document-query-fetch'
? {
...event,
content: resolveAiDocumentQueryFetchRunningText(intent),
status: 'running'
}
: event
))
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
try {
const payload = await fetchAiDocumentQueryPayload(intent)
const rawCount = extractExpenseClaimItems(payload).length
const filteredRecords = filterAiDocumentQueryRecords(payload, intent)
thinkingEvents = completeAiDocumentQueryEvent(
thinkingEvents,
'document-query-fetch',
`接口返回 ${rawCount} 张候选单据,开始按自然语言条件筛选。`
)
thinkingEvents = thinkingEvents.map((event) => (
event.eventId === 'document-query-filter'
? {
...event,
content: `按“${conditionSummary}”组合筛选,当前命中 ${filteredRecords.length} 张。`,
status: 'running'
}
: event
))
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
await waitForAiDocumentQueryStep()
const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
thinkingEvents = completeAiDocumentQueryEvent(
thinkingEvents,
'document-query-filter',
`筛选完成,命中 ${filteredRecords.length} 张单据,已生成卡片结果。`
)
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents
},
suggestedActions: []
})
)
} catch (error) {
const finalMessageText = error?.message || '查询单据时出现异常,请稍后再试。'
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: failAiDocumentQueryEvents(thinkingEvents)
}
})
)
}
persistCurrentConversation()
return true
}
return {
handleAiDocumentQueryIntent
}
}

View File

@@ -0,0 +1,186 @@
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import {
applyAiExpenseAnswer,
buildAiExpenseStepPrompt,
buildAiExpenseSummary,
createAiExpenseDraft,
isAiExpenseDraftComplete
} from '../../utils/aiExpenseDraftModel.js'
import {
buildExpenseSceneSelectionMessage,
SESSION_TYPE_EXPENSE
} from '../../views/scripts/travelReimbursementConversationModel.js'
import { buildExpenseSceneSelectionActions } from '../../utils/expenseAssistantActions.js'
import {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
export { SESSION_TYPE_EXPENSE }
export function useWorkbenchAiExpenseFlow({
activateInlineConversation,
aiExpenseDraft,
assistantDraft,
clearAiModeFiles,
closeWorkbenchDatePicker,
conversationMessages,
conversationStarted,
createInlineMessage,
currentUser,
persistCurrentConversation,
pushInlineUserMessage,
removeWorkbenchDateTag,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
startAiApplicationPreview
}) {
function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') {
const sourceText = String(originalMessage || '我要报销').trim()
if (!conversationStarted.value) {
activateInlineConversation({
title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销'
})
}
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
conversationMessages.value.push(createInlineMessage('user', String(selectedLabel || sourceText).trim()))
conversationMessages.value.push(createInlineMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), {
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function startAiApplicationPreviewFromAction(payload = {}, fallbackLabel = '') {
const expenseType = String(payload.expense_type || '').trim()
const expenseTypeLabel = String(payload.expense_type_label || fallbackLabel || '').trim()
return startAiApplicationPreview(
expenseType,
expenseTypeLabel,
payload.carry_text || resolveLatestInlineUserPrompt()
)
}
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
if (!conversationStarted.value) {
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
}
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
clearAiModeFiles()
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
if (requiresApplicationBeforeReimbursement) {
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel)
return
}
const draft = createAiExpenseDraft(expenseType, expenseTypeLabel)
aiExpenseDraft.value = draft
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function advanceAiExpenseDraft(answer, files = []) {
const fileNames = Array.from(files || [])
pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : ''))
assistantDraft.value = ''
clearAiModeFiles()
const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames)
aiExpenseDraft.value = next
if (isAiExpenseDraftComplete(next)) {
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮你生成报销草稿。`))
aiExpenseDraft.value = null
} else {
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
}
persistCurrentConversation()
scrollInlineConversationToBottom()
}
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
let claims = null
try {
claims = await fetchExpenseClaims()
} catch {
aiExpenseDraft.value = null
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
persistCurrentConversation()
scrollInlineConversationToBottom()
return
}
const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {})
aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel)
if (!candidates.length) {
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
suggestedActions: [{
label: '确认发起出差申请',
description: '生成完整申请表,并预填已识别的时间、地点和事由',
icon: 'mdi mdi-file-plus-outline',
action_type: 'ai_application_start_inline',
payload: {
expense_type: expenseType,
expense_type_label: expenseTypeLabel
}
}]
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
return
}
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationSelectionText(expenseType, candidates), {
suggestedActions: buildRequiredApplicationActions(candidates, 'select_required_application')
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function linkAiExpenseApplication(application = {}) {
const draft = aiExpenseDraft.value
if (!draft) {
return
}
const claimNo = String(application.application_claim_no || '').trim()
pushInlineUserMessage(`关联申请单 ${claimNo}`.trim())
const linked = {
...draft,
applicationClaim: application,
values: {
...draft.values,
reason: String(application.application_reason || '').trim(),
location: String(application.application_location || '').trim(),
time_range: String(application.application_business_time || '').trim(),
amount: String(application.application_amount_label || application.application_amount || '').trim()
},
stepKey: 'attachments'
}
aiExpenseDraft.value = linked
conversationMessages.value.push(createInlineMessage('assistant', [
`已关联申请单${claimNo ? ` ${claimNo}` : ''},事由、时间、地点、金额我先用申请单的内容预填了。`,
'',
'再确认一下票据:可以现在上传,或回复“稍后上传”。'
].join('\n')))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
return {
advanceAiExpenseDraft,
linkAiExpenseApplication,
pushInlineExpenseSceneSelectionPrompt,
startAiApplicationPreviewFromAction,
startAiExpenseDraft
}
}

View File

@@ -0,0 +1,33 @@
export function useWorkbenchAiMessageActions({
assistantDraft,
focusAiModeInput,
persistCurrentConversation,
toast
}) {
async function copyInlineMessage(message) {
try {
await navigator.clipboard?.writeText(message.content)
toast('已复制内容。')
} catch {
toast('当前浏览器暂不支持自动复制。')
}
}
function quoteInlineMessage(message) {
const quote = `> ${message.content}\n\n`
assistantDraft.value = assistantDraft.value ? assistantDraft.value + '\n' + quote : quote
focusAiModeInput()
}
function markInlineMessageFeedback(message, feedback) {
message.feedback = feedback
persistCurrentConversation()
toast(feedback === 'up' ? '已记录有帮助反馈。' : '已记录需要改进反馈。')
}
return {
copyInlineMessage,
markInlineMessageFeedback,
quoteInlineMessage
}
}

View File

@@ -0,0 +1,97 @@
export function useWorkbenchAiSessionCommands({
activeConversationTitle,
attachmentOcrExpandedMessageIds,
conversationId,
conversationMessages,
conversationStarted,
createInlineMessage,
currentUser,
deleteAiWorkbenchConversation,
emit,
focusAiModeInput,
inlineConversationAutoScrollPinned,
normalizeRuntimeMessage,
refreshConversationHistory,
resetInlineConversationState,
scrollInlineConversationToBottom,
stewardState,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds,
toast
}) {
function startNewInlineConversation() {
resetInlineConversationState()
emit('conversation-change', { id: '', title: '' })
refreshConversationHistory()
focusAiModeInput()
}
function openInlineSearchConversation(activateInlineConversation) {
conversationMessages.value = [
createInlineMessage('assistant', '你可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
]
stewardState.value = null
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
attachmentOcrExpandedMessageIds.value = new Set()
conversationId.value = 'ai-search'
activateInlineConversation({ id: 'ai-search', title: '查询对话' })
focusAiModeInput()
scrollInlineConversationToBottom()
}
function openInlineRecentConversation(item = {}) {
const title = String(item.title || '最近对话').trim()
conversationId.value = String(item.id || `recent-${Date.now()}`).trim()
activeConversationTitle.value = title
stewardState.value = item.stewardState || null
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
attachmentOcrExpandedMessageIds.value = new Set()
inlineConversationAutoScrollPinned.value = true
conversationMessages.value = Array.isArray(item.messages) && item.messages.length
? item.messages.map((message) => normalizeRuntimeMessage(message))
: [
createInlineMessage(
'assistant',
'这条历史对话没有保存完整消息。你可以继续输入新的问题,小财管家会接着处理。'
)
]
conversationStarted.value = true
emit('conversation-change', { id: conversationId.value, title })
focusAiModeInput()
scrollInlineConversationToBottom()
}
function requestDeleteCurrentConversation(deleteDialogOpen) {
if (!conversationMessages.value.length) {
return
}
deleteDialogOpen.value = true
}
function cancelDeleteConversation(deleteDialogOpen) {
deleteDialogOpen.value = false
}
function confirmDeleteConversation(deleteDialogOpen) {
const nextHistory = conversationId.value
? deleteAiWorkbenchConversation(currentUser.value || {}, conversationId.value)
: refreshConversationHistory()
emit('conversation-history-change', nextHistory)
resetInlineConversationState()
deleteDialogOpen.value = false
emit('conversation-change', { id: '', title: '' })
toast('已删除当前对话。')
focusAiModeInput()
}
return {
cancelDeleteConversation,
confirmDeleteConversation,
openInlineRecentConversation,
openInlineSearchConversation,
requestDeleteCurrentConversation,
startNewInlineConversation
}
}

View File

@@ -0,0 +1,371 @@
import {
fetchStewardPlan,
fetchStewardPlanStream
} from '../../services/steward.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import {
buildStewardPlanMessageText,
buildStewardPlanRequest,
buildStewardSuggestedActions,
normalizeStewardPlan
} from '../../views/scripts/stewardPlanModel.js'
import {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
function shouldCheckAiRequiredApplicationGate(prompt) {
const compact = String(prompt || '').replace(/\s+/g, '')
if (!compact || !/(出差|差旅|部署|实施|支撑|支持|协助|拜访|调研|驻场|上线|验收)/.test(compact)) {
return false
}
if (!/\d{1,2}月\d{1,2}|昨天|前天|上周|上月/.test(compact)) {
return false
}
return !/(申请|报销|草稿|提交|审批|保存|发起|创建)/.test(compact)
}
function serializeRequiredApplicationCandidate(candidate = {}) {
return {
id: String(candidate.id || '').trim(),
claim_no: String(candidate.claim_no || '').trim(),
reason: String(candidate.reason || '').trim(),
location: String(candidate.location || '').trim(),
business_time: String(candidate.business_time || '').trim(),
status_label: String(candidate.status_label || '').trim()
}
}
function resolveRequiredApplicationGateContinuationFlow(normalizedPlan) {
if (String(normalizedPlan?.pendingFlowConfirmation?.status || '').trim() !== 'pending') {
return null
}
const flows = Array.isArray(normalizedPlan?.candidateFlows) ? normalizedPlan.candidateFlows : []
const applicationFlow = flows.find((flow) => flow.flowId === 'travel_application')
if (flows.length === 1 && applicationFlow && /先发起出差申请/.test(applicationFlow.label)) {
return applicationFlow
}
return flows.find((flow) => (
flow.flowId === 'travel_reimbursement' &&
/关联已有申请单/.test(flow.label)
)) || null
}
function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
const baseText = buildStewardPlanMessageText({
planStatus: normalizedPlan?.planStatus,
nextAction: normalizedPlan?.nextAction,
summary: normalizedPlan?.summary,
pendingFlowConfirmation: normalizedPlan?.pendingFlowConfirmation,
candidateFlows: normalizedPlan?.candidateFlows
})
const contextText = String(baseText || '')
.split(/\n\n1\. \*\*/)[0]
.trim()
.replace('### 需要先确认流程方向', '### 我已先查询申请单')
if (flow?.flowId === 'travel_application') {
return [
contextText || baseText,
'这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。'
].filter(Boolean).join('\n\n')
}
if (flow?.flowId === 'travel_reimbursement') {
return [
contextText || baseText,
'这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。'
].filter(Boolean).join('\n\n')
}
return baseText
}
function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
if (flow.flowId === 'travel_application') {
return [{
label: '确认发起出差申请',
description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。',
icon: 'mdi mdi-file-plus-outline',
action_type: 'ai_application_start_inline',
payload: {
expense_type: 'travel',
expense_type_label: '差旅费',
carry_text: prompt
}
}]
}
if (flow.flowId === 'travel_reimbursement') {
return [{
label: '确认关联已有申请单',
description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。',
icon: 'mdi mdi-link-variant',
action_type: 'steward_confirm_flow',
payload: {
steward_confirm_flow: true,
flow_id: 'travel_reimbursement',
expense_type: 'travel',
expense_type_label: '差旅费',
carry_text: prompt
}
}]
}
return []
}
function normalizeStreamThinkingEvent(event = {}) {
const data = event?.data && typeof event.data === 'object' ? event.data : {}
const eventId = String(data.event_id || data.eventId || data.stage || `thinking-${Date.now()}`).trim()
return {
eventId,
stage: String(data.stage || '').trim(),
title: String(data.title || '小财管家正在分析').trim(),
content: String(data.content || '').trim(),
status: String(data.status || 'running').trim() || 'running'
}
}
export function useWorkbenchAiStewardFlow({
activeConversationTitle,
collectAiModeReceiptContext,
conversationId,
conversationMessages,
createInlineMessage,
currentUser,
deleteAiWorkbenchConversation,
emit,
handleAiDocumentQueryIntent,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
resolveInlineThinkingEvents,
scrollInlineConversationToBottom,
sending,
stewardState,
streamInlineAssistantContent,
updateInlineMessageContent,
appendInlineMessageContent,
toast
}) {
async function attachAiRequiredApplicationGate(planRequest, prompt) {
if (!shouldCheckAiRequiredApplicationGate(prompt)) {
return planRequest
}
try {
const claims = await fetchExpenseClaims()
const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
planRequest.context_json = {
...(planRequest.context_json || {}),
required_application_gate: {
...((planRequest.context_json || {}).required_application_gate || {}),
travel: {
checked: true,
candidate_count: candidates.length,
candidates: candidates.slice(0, 5).map((candidate) => serializeRequiredApplicationCandidate(candidate))
}
}
}
} catch (error) {
console.warn('AI mode required application lookup failed:', error)
planRequest.context_json = {
...(planRequest.context_json || {}),
required_application_gate: {
...((planRequest.context_json || {}).required_application_gate || {}),
travel: {
checked: false,
query_failed: true
}
}
}
}
return planRequest
}
function handleInlineStewardStreamEvent(messageId, event) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message) {
return
}
if (event?.event === 'answer_delta') {
const data = event?.data && typeof event.data === 'object' ? event.data : {}
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
appendInlineMessageContent(message, data.delta || data.content || data.text || '')
message.stewardPlan = {
...(message.stewardPlan || {}),
streamStatus: 'streaming'
}
scrollInlineConversationToBottom({ force: shouldAutoScroll })
return
}
if (event?.event !== 'thinking') {
return
}
const nextEvent = normalizeStreamThinkingEvent(event)
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
const currentPlan = message.stewardPlan || {}
const currentEvents = Array.isArray(currentPlan.thinkingEvents) ? currentPlan.thinkingEvents : []
const eventIndex = currentEvents.findIndex((item) => item.eventId && item.eventId === nextEvent.eventId)
const nextEvents = eventIndex >= 0
? currentEvents.map((item, index) => (index === eventIndex ? { ...item, ...nextEvent } : item))
: [...currentEvents, nextEvent]
message.stewardPlan = {
...currentPlan,
thinkingEvents: nextEvents,
streamStatus: 'streaming'
}
scrollInlineConversationToBottom({ force: shouldAutoScroll })
}
async function fetchInlineStewardPlan(messageId, payload) {
try {
return await fetchStewardPlanStream(
payload,
{
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event)
},
{
idleTimeoutMs: 90000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
}
)
} catch (error) {
if (String(error?.message || '').includes('流式服务')) {
return fetchStewardPlan(payload, {
timeoutMs: 75000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
})
}
throw error
}
}
async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
let shouldAutoScrollOnFinish = true
const pendingMessage = createInlineMessage('assistant', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: [
{
eventId: 'init',
title: '小财管家正在接入业务流程',
content: '正在识别你的意图、上下文和附件信息。',
status: 'running'
}
]
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
return
}
const receiptContext = await collectAiModeReceiptContext(files)
const planRequest = buildStewardPlanRequest({
rawText: prompt,
files,
currentUser: currentUser.value || {},
conversationId: conversationId.value,
stewardState: stewardState.value
})
planRequest.context_json = {
...planRequest.context_json,
entry_source: 'workbench_ai_inline',
source: entry.source || 'workbench',
attachment_names: receiptContext.attachmentNames,
attachment_count: receiptContext.attachmentCount,
ocr_summary: receiptContext.ocrSummary,
ocr_documents: receiptContext.ocrDocuments,
ocr_source_file_names: receiptContext.ocrSourceFileNames,
...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {})
}
await attachAiRequiredApplicationGate(planRequest, prompt)
const plan = await fetchInlineStewardPlan(pendingMessage.id, planRequest)
const normalizedPlan = normalizeStewardPlan(plan, {
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true
})
const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
const nextThinkingEvents = normalizedPlan.thinkingEvents.length
? normalizedPlan.thinkingEvents
: previousThinkingEvents.map((item) => ({ ...item, status: 'completed' }))
const previousConversationId = conversationId.value
const nextConversationId = String(normalizedPlan.conversationId || '').trim()
if (nextConversationId) {
conversationId.value = nextConversationId
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
if (previousConversationId && previousConversationId !== nextConversationId) {
deleteAiWorkbenchConversation(currentUser.value || {}, previousConversationId)
}
}
if (normalizedPlan.stewardState) {
stewardState.value = normalizedPlan.stewardState
}
const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
const finalMessageText = requiredApplicationContinuationFlow
? buildAiRequiredApplicationGateAutoMessage(normalizedPlan, requiredApplicationContinuationFlow)
: buildStewardPlanMessageText(plan)
const hasServerStreamedContent = Boolean(String(pendingMessage.content || '').trim())
if (!hasServerStreamedContent) {
await streamInlineAssistantContent(pendingMessage.id, finalMessageText)
}
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
...normalizedPlan,
thinkingEvents: nextThinkingEvents,
streamStatus: 'completed'
},
suggestedActions: requiredApplicationContinuationFlow
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
: buildStewardSuggestedActions(plan)
})
)
persistCurrentConversation()
} catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage(
'assistant',
error?.message || '小财管家暂时无法完成规划,请稍后再试。',
{
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
...item,
status: 'failed'
}))
}
}
)
)
toast(error?.message || '小财管家暂时无法完成规划。')
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
}
}
return {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates,
requestInlineAssistantReply
}
}

View File

@@ -0,0 +1,340 @@
import {
buildApplicationTemplatePreview,
buildLocalApplicationPreview,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { AI_APPLICATION_DETAIL_HREF_PREFIX } from '../../utils/aiDocumentDetailReference.js'
import {
buildAiApplicationPrecheckThinkingEvents,
isAiApplicationPrecheckBlocking
} from '../../utils/aiApplicationPrecheckModel.js'
import { extractExpenseClaimItems } from '../../services/reimbursements.js'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
} from '../../services/aiApplicationPreviewActions.js'
const INLINE_APPLICATION_STATUS_LABELS = {
draft: '草稿',
submitted: '审批中',
pending: '待处理',
approved: '已审批',
completed: '已完成',
archived: '已归档',
returned: '已退回',
rejected: '已驳回',
pending_payment: '待付款',
paid: '已付款'
}
function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
const text = String(value || '')
.replace(/\s*\n+\s*/g, ' ')
.replace(/\|/g, '')
.trim()
return text || fallback
}
export function normalizeInlineApplicationStatusLabel(value, fallback = '') {
const text = String(value || '').trim()
if (!text) {
return fallback
}
return INLINE_APPLICATION_STATUS_LABELS[text.toLowerCase()] || text
}
export function buildInlineApplicationActionDetailHref(reference = '') {
const source = reference && typeof reference === 'object' ? reference : { reference }
const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
const fallback = String(source.reference || '').trim()
if (claimId || claimNo) {
const params = new URLSearchParams()
if (claimId) {
params.set('claim_id', claimId)
}
if (claimNo) {
params.set('claim_no', claimNo)
}
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
}
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
}
export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
const body = String(source.body || source.markdown || '').trim()
const resolveBodyField = (labels = []) => {
for (const label of labels) {
const pattern = new RegExp(`${label}\\s*[:]\\s*([^\\n|]+)`, 'u')
const match = body.match(pattern)
if (match?.[1]) {
return String(match[1]).replace(/\*\*/g, '').trim()
}
}
return ''
}
const startDate = String(source.start_date || source.startDate || source.trip_start_date || source.tripStartDate || '').trim()
const endDate = String(source.end_date || source.endDate || source.trip_end_date || source.tripEndDate || '').trim()
const dateText = String(
source.business_time ||
source.businessTime ||
source.time ||
source.occurred_at ||
source.occurredAt ||
source.apply_time ||
source.applyTime ||
''
).trim()
const rangeText = startDate && endDate && startDate !== endDate
? `${startDate}${endDate}`
: startDate || endDate
return {
claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim(),
claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
statusLabel: normalizeInlineApplicationStatusLabel(source.status_label || source.statusLabel || source.status),
approvalStage: String(source.approval_stage || source.approvalStage || '').trim(),
dateLabel: rangeText || dateText || resolveBodyField(['时间', '日期', '申请时间']) || '待补充',
locationLabel: String(
source.location ||
source.application_location ||
source.applicationLocation ||
source.destination ||
source.destination_city ||
source.destinationCity ||
''
).trim() || resolveBodyField(['地点', '目的地']) || '待补充',
reasonLabel: String(
source.reason ||
source.business_reason ||
source.businessReason ||
source.description ||
source.title ||
''
).trim() || resolveBodyField(['事由', '事件', '申请事由']) || '待补充',
amountLabel: String(
source.amount ||
source.application_amount ||
source.applicationAmount ||
source.estimated_amount ||
source.estimatedAmount ||
''
).trim() || resolveBodyField(['金额', '预计金额', '申请金额']) || '-',
documentTypeLabel: String(
source.document_type_label ||
source.documentTypeLabel ||
source.application_type_label ||
source.applicationTypeLabel ||
source.expense_type_label ||
source.expenseTypeLabel ||
''
).trim()
}
}
export function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
const reference = info.claimNo || info.claimId
const href = buildInlineApplicationActionDetailHref(info)
const actionText = href ? `[查看](${href})` : '-'
const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel)
return [
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |',
'| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
`| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} | ${actionText} |`
].join('\n')
}
export function extractInlineApplicationDraftPayload(payload = {}) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
return result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: payload?.draft_payload && typeof payload.draft_payload === 'object'
? payload.draft_payload
: null
}
export function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) {
const draftPayload = extractInlineApplicationDraftPayload(payload) || {}
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
const approvalStage = String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim()
if (actionType === AI_APPLICATION_ACTION_SUBMIT) {
return [
'### 申请单据已生成,并已进入审批流程',
approvalStage ? `系统已推送到 **${approvalStage}**,当前节点:${approvalStage}` : '系统已推送到审批流程,当前节点:审批中。',
buildInlineApplicationResultTable(draftPayload, {
statusLabel: '审批中',
stageLabel: approvalStage || '直属领导审批',
documentTypeLabel: '出差申请'
}),
'需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。'
].filter(Boolean).join('\n\n')
}
return [
'### 申请草稿已保存',
claimNo ? `系统已保存当前申请草稿,草稿单号:**${claimNo}**。` : '系统已保存当前申请草稿。',
buildInlineApplicationResultTable(draftPayload, {
statusLabel: '草稿',
stageLabel: '待提交',
documentTypeLabel: '出差申请'
}),
'后续请点击卡片“操作”行的“查看”进入详情页继续核对。'
].filter(Boolean).join('\n\n')
}
export function buildInlineApplicationDetailAction(draftPayload = {}) {
const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
if (!claimNo) {
return []
}
return [{
label: '查看单据详情',
description: '打开刚生成的申请单详情。',
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {
claim_no: claimNo,
claim_id: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
document_type: 'application'
}
}]
}
export function resolveInlineApplicationPreviewActionFromText(text = '') {
const normalized = String(text || '').replace(/\s+/g, '').trim()
if (!normalized) {
return ''
}
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
return AI_APPLICATION_ACTION_SAVE_DRAFT
}
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
return AI_APPLICATION_ACTION_SUBMIT
}
return ''
}
export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {
const label = String(expenseTypeLabel || '').trim()
if (!label) {
return fallback
}
if (label.endsWith('费用申请') || label.endsWith('申请')) {
return label
}
if (label.endsWith('费用')) {
return `${label}申请`
}
if (label.endsWith('费')) {
return `${label.slice(0, -1)}费用申请`
}
return `${label}申请`
}
export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}) {
const rawText = String(sourceText || '').trim()
const preview = rawText
? buildLocalApplicationPreview(rawText, currentUser)
: buildApplicationTemplatePreview(currentUser)
const normalized = normalizeApplicationPreview(preview)
return normalizeApplicationPreview({
...normalized,
fields: {
...(normalized.fields || {}),
applicationType: normalizeInlineApplicationTypeLabel(
expenseTypeLabel,
normalized.fields?.applicationType || '费用申请'
)
}
})
}
export function resolveInlineApplicationDraftIdentity(payload = {}) {
const source = payload && typeof payload === 'object' ? payload : {}
return {
claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim()
}
}
export function isSameInlineApplicationDraftClaim(claim = {}, draftPayload = {}) {
const draftIdentity = resolveInlineApplicationDraftIdentity(draftPayload)
if (!draftIdentity.claimId && !draftIdentity.claimNo) {
return false
}
const claimIdentity = resolveInlineApplicationDraftIdentity(claim)
return Boolean(
(draftIdentity.claimId && claimIdentity.claimId && draftIdentity.claimId === claimIdentity.claimId) ||
(draftIdentity.claimNo && claimIdentity.claimNo && draftIdentity.claimNo === claimIdentity.claimNo)
)
}
export function buildInlineApplicationSubmitPrecheckPayload(claimsPayload, draftPayload = {}) {
const items = extractExpenseClaimItems(claimsPayload)
.filter((claim) => !isSameInlineApplicationDraftClaim(claim, draftPayload))
return { items }
}
export function completeInlineThinkingEvents(events = []) {
return events.map((event) => ({
...event,
status: event.status === 'failed' ? 'failed' : 'completed'
}))
}
export function buildInitialInlineApplicationSubmitThinkingEvents() {
return [
{
eventId: 'application-precheck-overlap',
title: '核查同时间段申请单',
content: '正在查询你名下可见申请单,检查是否存在相同或重叠日期。',
status: 'running'
},
{
eventId: 'application-precheck-budget',
title: '评估预算与审批影响',
content: '等待单据重叠核查完成后,继续评估预算占用和审批影响。',
status: 'pending'
},
{
eventId: 'application-submit',
title: '提交申请单据',
content: '等待提交前核查完成。',
status: 'pending'
}
]
}
export function buildInlineApplicationSubmitThinkingEvents(precheck = {}) {
const blocked = isAiApplicationPrecheckBlocking(precheck)
return buildAiApplicationPrecheckThinkingEvents(precheck).map((event) => {
if (event.eventId !== 'application-precheck-form') {
return event
}
return {
eventId: 'application-submit',
title: blocked ? '暂停提交申请' : '提交申请单据',
content: blocked
? '发现相同或重叠日期已有申请单,已暂停本次提交。'
: '提交前核查通过,正在生成申请单据并推送审批流程。',
status: blocked ? 'completed' : 'running'
}
})
}
export function buildFailedInlineApplicationSubmitThinkingEvents(error) {
return [
{
eventId: 'application-precheck-overlap',
title: '核查同时间段申请单',
content: `查询已有申请单失败:${String(error?.message || error || '未知错误')}`,
status: 'failed'
},
{
eventId: 'application-submit',
title: '暂停提交申请',
content: '因为未能完成提交前重复日期核查,系统没有提交本次申请。',
status: 'failed'
}
]
}

View File

@@ -0,0 +1,100 @@
import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js'
export const AI_COMPOSER_FILE_TYPE_META = {
pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' },
image: { label: '图片', icon: 'mdi mdi-file-image-outline', tone: 'image' },
spreadsheet: { label: '表格', icon: 'mdi mdi-file-excel-outline', tone: 'spreadsheet' },
document: { label: '文档', icon: 'mdi mdi-file-document-outline', tone: 'document' },
archive: { label: '压缩包', icon: 'mdi mdi-folder-zip-outline', tone: 'archive' },
file: { label: '文件', icon: 'mdi mdi-file-outline', tone: 'file' }
}
export const AI_MODE_ACTION_ITEMS = [
{
label: '发起报销',
icon: 'mdi mdi-file-document-plus-outline',
prompt: '帮我发起一笔报销,并检查需要准备哪些票据材料。',
source: 'workbench',
sessionType: 'expense'
},
{
label: '查询预算',
icon: 'mdi mdi-chart-pie-outline',
prompt: '帮我查询当前预算余额和近期费用占用情况。',
source: 'budget',
sessionType: 'budget'
},
{
label: '解释制度',
icon: 'mdi mdi-book-open-page-variant-outline',
prompt: '帮我解释公司报销制度,并列出这次需要注意的条款。',
source: 'workbench',
sessionType: 'knowledge'
},
{
label: '催办审批',
icon: 'mdi mdi-bell-ring-outline',
prompt: '帮我查询待审批单据,并生成一段礼貌的催办说明。',
source: 'workbench',
sessionType: 'approval'
}
]
export function resolveAiComposerFileName(file) {
return String(file?.name || '未命名附件').trim() || '未命名附件'
}
export function resolveAiComposerFileType(file) {
const fileName = resolveAiComposerFileName(file).toLowerCase()
const mimeType = String(file?.type || '').toLowerCase()
const extension = fileName.includes('.') ? fileName.split('.').pop() : ''
if (extension === 'pdf' || mimeType.includes('pdf')) {
return AI_COMPOSER_FILE_TYPE_META.pdf
}
if (/^(png|jpe?g|gif|webp|bmp|svg|heic)$/.test(extension) || mimeType.startsWith('image/')) {
return AI_COMPOSER_FILE_TYPE_META.image
}
if (/^(xls|xlsx|csv|numbers)$/.test(extension) || mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
return AI_COMPOSER_FILE_TYPE_META.spreadsheet
}
if (/^(doc|docx|txt|md|pages)$/.test(extension) || mimeType.includes('word') || mimeType.includes('text')) {
return AI_COMPOSER_FILE_TYPE_META.document
}
if (/^(zip|rar|7z|tar|gz)$/.test(extension) || mimeType.includes('zip') || mimeType.includes('compressed')) {
return AI_COMPOSER_FILE_TYPE_META.archive
}
return AI_COMPOSER_FILE_TYPE_META.file
}
export function buildSelectedFileCards(files = []) {
return files.map((file) => ({
key: buildFileIdentity(file),
name: resolveAiComposerFileName(file),
...resolveAiComposerFileType(file)
}))
}
export function isLikelyAiModeOcrFile(file = {}) {
const name = String(file?.name || '').trim()
const type = String(file?.type || '').trim()
return /\.(pdf|jpe?g|png|webp|bmp)$/i.test(name) || /^(image\/|application\/pdf)/i.test(type)
}
export function isLikelyReceiptAssociationFile(file = {}) {
return isLikelyAiModeOcrFile(file)
}
export function shouldKeepAiAttachmentInAssistantReply(prompt = '') {
const compact = String(prompt || '').replace(/\s+/g, '')
return /(OCR|ocr|识别|票面|票据内容|发票内容|文字|读一下|看一下)/.test(compact)
}
export function shouldRunAiAttachmentAutoAssociation(entry = {}, files = [], prompt = '') {
return Boolean(
Array.isArray(files) &&
files.length &&
files.every((file) => isLikelyReceiptAssociationFile(file)) &&
!shouldKeepAiAttachmentInAssistantReply(prompt) &&
String(entry?.sessionType || '').trim() === 'steward'
)
}

View File

@@ -0,0 +1,195 @@
export const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'
export const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'
function normalizeParagraphs(content) {
return String(content || '')
.split(/\n{2,}|\n/)
.map((item) => item.trim())
.filter(Boolean)
}
function stripInlineAssociationMarkdown(value = '') {
return String(value || '')
.replace(/\*\*/g, '')
.replace(/`/g, '')
.trim()
}
export function resolveLegacyAiAttachmentAssociationPayload(content = '') {
const text = String(content || '')
if (!/我已先识别票据,并(?:匹配到最可能的报销单|找到一张可能关联的报销单)/.test(text)) {
return null
}
const claimNo = stripInlineAssociationMarkdown(
text.match(/推荐关联[:]\s*([^\n]+)/u)?.[1] || ''
)
if (!claimNo) {
return null
}
return {
claim_no: claimNo,
document_type: 'expense'
}
}
export function hydrateInlineAttachmentAssociationSuggestedActions(actions = [], content = '') {
const safeActions = Array.isArray(actions) ? actions : []
const hasConfirmAction = safeActions.some(
(action) => String(action?.action_type || '').trim() === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION
)
if (hasConfirmAction) {
return safeActions
}
const payload = resolveLegacyAiAttachmentAssociationPayload(content)
if (!payload) {
return safeActions
}
return [
{
label: '确认自动关联',
description: '将本次票据自动归集到推荐单据。',
icon: 'mdi mdi-link-variant',
action_type: AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
payload
},
...safeActions
]
}
function normalizeInlineAttachmentOcrField(field = {}) {
if (!field || typeof field !== 'object') {
return null
}
const value = String(field.value ?? field.text ?? '').trim()
if (!value) {
return null
}
return {
label: String(field.label || field.key || field.name || '识别字段').trim() || '识别字段',
value
}
}
function normalizeInlineAttachmentOcrDocument(document = {}, index = 0) {
const fields = (Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || [])
.map((field) => normalizeInlineAttachmentOcrField(field))
.filter(Boolean)
.slice(0, 12)
const summary = String(document?.summary || document?.text || '').replace(/\s+/g, ' ').trim()
const filename = String(document?.filename || document?.name || '').trim()
if (!filename && !summary && !fields.length) {
return null
}
return {
filename: filename || `附件 ${index + 1}`,
summary,
fields
}
}
export function normalizeInlineAttachmentOcrDetails(details = null) {
if (!details || typeof details !== 'object') {
return null
}
const documents = (Array.isArray(details.documents) ? details.documents : details.ocrDocuments || [])
.map((document, index) => normalizeInlineAttachmentOcrDocument(document, index))
.filter(Boolean)
const fileNames = (Array.isArray(details.fileNames) ? details.fileNames : [])
.map((name) => String(name || '').trim())
.filter(Boolean)
if (!documents.length && !fileNames.length) {
return null
}
return {
fileNames,
documents
}
}
export function buildInlineAttachmentOcrDetails(collected = {}, files = []) {
return normalizeInlineAttachmentOcrDetails({
fileNames: files.map((file) => file?.name || '').filter(Boolean),
documents: collected?.ocrDocuments || []
})
}
export function formatMessageTime(timestamp) {
if (!timestamp) return ''
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function createWorkbenchAiMessageRuntime() {
let messageSeq = 0
function nextMessageId() {
messageSeq += 1
return `${Date.now()}-${messageSeq}`
}
function createAiAttachmentAssociationId() {
messageSeq += 1
return `ai-attachment-${Date.now()}-${messageSeq}`
}
function createInlineMessage(role, content, options = {}) {
const normalizedContent = String(content || '').trim()
const suggestedActions = Array.isArray(options.suggestedActions) ? options.suggestedActions : []
return {
id: options.id || nextMessageId(),
role,
content: normalizedContent,
paragraphs: normalizeParagraphs(normalizedContent),
pending: Boolean(options.pending),
feedback: String(options.feedback || ''),
stewardPlan: options.stewardPlan || null,
suggestedActions: role === 'assistant'
? hydrateInlineAttachmentAssociationSuggestedActions(suggestedActions, normalizedContent)
: suggestedActions,
applicationPreview: options.applicationPreview || null,
draftPayload: options.draftPayload || null,
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
text: options.text || normalizedContent,
createdAt: options.createdAt || Date.now()
}
}
function normalizeRuntimeMessage(message = {}) {
return createInlineMessage(message.role || 'assistant', message.content || '', {
id: message.id,
pending: false,
feedback: message.feedback || '',
stewardPlan: message.stewardPlan || null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
applicationPreview: message.applicationPreview || null,
draftPayload: message.draftPayload || null,
attachmentOcrDetails: message.attachmentOcrDetails || null,
text: message.text || message.content || ''
})
}
function serializeRuntimeMessage(message = {}) {
return {
id: message.id,
role: message.role,
content: message.content,
text: message.text || message.content || '',
feedback: message.feedback || '',
stewardPlan: message.stewardPlan || null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
applicationPreview: message.applicationPreview || null,
draftPayload: message.draftPayload || null,
attachmentOcrDetails: message.attachmentOcrDetails || null
}
}
return {
createAiAttachmentAssociationId,
createInlineMessage,
normalizeRuntimeMessage,
serializeRuntimeMessage
}
}