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

1085 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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, fetchExpenseClaimDetail } from '../../services/reimbursements.js'
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
import {
deleteAiWorkbenchConversation,
loadAiWorkbenchConversationHistory,
markAiWorkbenchConversationDocumentDeleted,
saveAiWorkbenchConversation
} from '../../utils/aiWorkbenchConversationStore.js'
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
import {
buildAiDocumentDetailRequest,
parseAiApplicationDetailHref,
parseAiDocumentDetailHref
} from '../../utils/aiDocumentDetailReference.js'
import {
AI_MODE_ACTION_ITEMS,
shouldRunAiAttachmentAutoAssociation
} from './workbenchAiComposerModel.js'
import {
createWorkbenchAiMessageRuntime,
formatMessageTime
} 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 { useWorkbenchAiFilePreview } from './useWorkbenchAiFilePreview.js'
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
import {
isReimbursementCreationIntent
} from './workbenchAiApplicationGateModel.js'
import { useWorkbenchAiCommandIntents } from './useWorkbenchAiCommandIntents.js'
import {
buildRuleFallbackWorkbenchAiIntentPlan,
isLowConfidenceTravelApplicationPlan,
normalizeWorkbenchAiIntentPlan,
resolveExecutableTravelApplicationPlan,
shouldRequestWorkbenchAiIntentPlan
} from './workbenchAiIntentPlannerModel.js'
import {
buildInitialModelPlanningThinkingEvents,
buildModelPlanningProgressSchedule,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.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,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
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 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,
resolveInputLockedMessage: () => resolveAiModeInputLockMessage(),
selectedFiles,
toast
})
const documentQueryFlow = useWorkbenchAiDocumentQueryFlow({
conversationMessages,
createInlineMessage,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
scrollInlineConversationToBottom
})
const commandIntents = useWorkbenchAiCommandIntents({
activateInlineConversation,
activeConversationTitle,
assistantDraft,
clearAiModeFiles: filesFlow.clearAiModeFiles,
closeWorkbenchDatePicker,
conversationId,
conversationMessages,
createInlineMessage,
documentQueryFlow,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
removeWorkbenchDateTag,
scrollInlineConversationToBottom,
searchConversationId: AI_SEARCH_CONVERSATION_ID,
sending
})
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
aiAttachmentAssociationRuntime,
conversationId,
conversationMessages,
createAiAttachmentAssociationId,
createInlineMessage,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
replaceInlineMessage,
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
notifyRequestUpdated: (payload) => emit('request-updated', payload),
toast
})
const filePreview = useWorkbenchAiFilePreview({
attachmentFlow,
conversationStarted,
scrollInlineConversationToBottom,
selectedFiles
})
const {
hasInlineAttachmentOcrDetails,
hasInlineThinking,
isInlineAttachmentOcrExpanded,
isInlineThinkingExpanded,
resolveInlineAttachmentOcrDocuments,
resolveInlineAttachmentOcrFileCount,
resolveInlineThinkingEvents,
toggleInlineAttachmentOcrDetails,
toggleInlineThinking
} = useWorkbenchAiMessageExpansion({
attachmentOcrExpandedMessageIds,
inlineConversationAutoScrollPinned,
scrollInlineConversationToBottom,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds
})
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,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveInlineThinkingEvents,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
sending,
toast
})
const expenseFlow = useWorkbenchAiExpenseFlow({
activateInlineConversation,
aiExpenseDraft,
assistantDraft,
clearAiModeFiles: filesFlow.clearAiModeFiles,
closeWorkbenchDatePicker,
conversationMessages,
conversationStarted,
createInlineMessage,
currentUser,
persistCurrentConversation,
pushInlineUserMessage,
replaceInlineMessage,
removeWorkbenchDateTag,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
startAiApplicationPreview: applicationFlow.startAiApplicationPreview
})
const actionRouter = useWorkbenchAiActionRouter({
aiExpenseDraft,
applicationFlow,
assistantDraft,
attachmentFlow,
conversationMessages,
createInlineMessage,
emit,
expenseFlow,
focusAiModeInput,
hasInlineAttachmentOcrDetails,
persistCurrentConversation,
replaceInlineMessage,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
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 isAiModeReceiptRecognitionPending = computed(() => attachmentFlow.hasPendingAiModeReceiptRecognition(selectedFiles.value))
const hasAiModeReceiptRecognitionFailure = computed(() => attachmentFlow.hasFailedAiModeReceiptRecognition(selectedFiles.value))
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value || isAiModeReceiptRecognitionPending.value)
const aiModeInputLockMessage = computed(() => resolveAiModeInputLockMessage())
const canSubmitAiModePrompt = computed(() => (
!isAiModeInputLocked.value &&
!hasAiModeReceiptRecognitionFailure.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 setAssistantInputRef(element) {
assistantInputRef.value = element
}
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 isUnavailableDocumentDetailError(error) {
const message = String(error?.message || '').trim()
return /not\s*found|不存在|已删除|不可访问|无权|forbidden|404/i.test(message)
}
function resolveDocumentDetailLookupClaimId(detailRequest = {}) {
return String(detailRequest.claimId || detailRequest.claim_id || '').trim()
}
function markActiveAiDocumentDetailLinkDeleted(detailRequest = {}) {
const nextHistory = markAiWorkbenchConversationDocumentDeleted(currentUser.value || {}, {
claimId: detailRequest.claimId,
claim_id: detailRequest.claimId,
claimNo: detailRequest.claimNo,
claim_no: detailRequest.claimNo,
documentNo: detailRequest.documentNo,
document_no: detailRequest.documentNo,
id: detailRequest.id
})
emit('conversation-history-change', nextHistory)
const activeConversation = nextHistory.find((item) => (
String(item.id || item.conversationId || '').trim() === String(conversationId.value || '').trim()
))
if (activeConversation?.messages?.length) {
conversationMessages.value = activeConversation.messages.map((message) => normalizeRuntimeMessage(message))
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
}
async function ensureAiDocumentDetailStillAvailable(detailRequest = {}) {
const claimId = resolveDocumentDetailLookupClaimId(detailRequest)
if (!claimId) {
return true
}
try {
await fetchExpenseClaimDetail(claimId)
return true
} catch (error) {
if (!isUnavailableDocumentDetailError(error)) {
console.warn('AI document detail availability check failed, continuing navigation:', error)
return true
}
markActiveAiDocumentDetailLinkDeleted(detailRequest)
toast('该单据已经删除或不可访问,已将这条历史入口标记为不可查看。')
return false
}
}
function buildInlinePromptText(rawPrompt, files = []) {
const prompt = buildWorkbenchPromptText(rawPrompt)
if (prompt) return prompt
return files.length ? '请帮我处理已上传的附件。' : ''
}
function resolveAiModeInputLockMessage() {
if (isAiModeReceiptRecognitionPending.value) {
return '附件识别中,请稍等...'
}
if (applicationPreviewEstimatePending.value) {
return '费用测算中,请稍等...'
}
return ''
}
function resolveAiModeSubmitBlockedMessage() {
if (applicationPreviewEstimatePending.value) {
return '请等待费用测算完成后再继续操作。'
}
if (isAiModeReceiptRecognitionPending.value) {
return '附件 OCR 识别中,请稍等,识别完成后再继续对话。'
}
if (hasAiModeReceiptRecognitionFailure.value) {
return '请先移除识别失败的附件或重新上传。'
}
return ''
}
function ensureAiModeCanStartConversation() {
const blockedMessage = resolveAiModeSubmitBlockedMessage()
if (!blockedMessage) {
return true
}
toast(blockedMessage)
focusAiModeInput()
return false
}
function isModelPlannedReimbursementTask(modelPlan = {}) {
const tasks = Array.isArray(modelPlan?.tasks) ? modelPlan.tasks : []
return tasks.some((task) => {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
return taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
})
}
function updateModelPlanningThinkingEvent(messageId, event) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message) {
return
}
const currentPlan = message.stewardPlan || {}
message.stewardPlan = {
...currentPlan,
streamStatus: 'streaming',
thinkingEvents: mergeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(message), [event])
}
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
function startModelPlanningProgressUpdates(messageId) {
const timerIds = buildModelPlanningProgressSchedule().map(({ delayMs, event }) => (
globalThis.setTimeout(() => {
updateModelPlanningThinkingEvent(messageId, event)
}, delayMs)
))
return () => {
timerIds.forEach((timerId) => globalThis.clearTimeout(timerId))
}
}
function startModelPlanningConversation(cleanPrompt, entry = {}) {
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
conversationId.value = ''
conversationMessages.value = []
activeConversationTitle.value = ''
}
activateInlineConversation({
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
})
inlineConversationAutoScrollPinned.value = true
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
filesFlow.clearAiModeFiles()
const pendingMessage = createInlineMessage('assistant', '正在识别意图,准备拆解申请、报销和附件任务。', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildInitialModelPlanningThinkingEvents()
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
persistCurrentConversation()
return pendingMessage
}
function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
void applicationFlow.startAiApplicationPreview(
travelApplicationRequest.expenseType,
travelApplicationRequest.expenseTypeLabel,
travelApplicationRequest.sourceText,
{
userMessage: travelApplicationRequest.sourceText,
pushUserMessage: !plannerPendingMessage,
pendingMessageId: plannerPendingMessage?.id,
ontologyFields: travelApplicationRequest.ontologyFields,
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
}
)
}
function startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, plan, plannerPendingMessage) {
const confirmText = buildLowConfidenceTravelApplicationConfirmationText(travelApplicationRequest, plan)
const confirmAction = {
label: '确认发起出差申请',
description: '根据上面识别到的信息生成出差申请预览。',
icon: 'mdi mdi-check-circle-outline',
action_type: 'ai_application_confirm_intent',
payload: {
ontologyFields: travelApplicationRequest.ontologyFields,
sourceText: travelApplicationRequest.sourceText,
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
requestedSubmit: travelApplicationRequest.requestedSubmit,
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
}
}
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, {
id: plannerPendingMessage.id,
suggestedActions: [confirmAction],
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' }))
}
}))
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
function buildLowConfidenceTravelApplicationConfirmationText(request, plan) {
const fields = request.ontologyFields || {}
const summaryParts = []
if (fields.time_range) {
summaryParts.push(`时间:${fields.time_range}`)
}
if (fields.location) {
summaryParts.push(`地点:${fields.location}`)
}
if (fields.reason) {
summaryParts.push(`事由:${fields.reason}`)
}
if (fields.transport_mode) {
summaryParts.push(`交通:${fields.transport_mode}`)
}
const summary = summaryParts.length ? `\n\n${summaryParts.join('')}` : ''
const confidenceNote = Number.isFinite(Number(plan?.confidence))
? `(模型识别置信度较低,约 ${Math.round(Number(plan.confidence) * 100)}%`
: '(模型识别置信度较低)'
return [
'### 需要确认:您是要发起出差申请吗?',
'',
`小财管家把这句话理解成了“发起差旅申请”${confidenceNote},为避免误操作,先请您确认。`,
summary,
'',
'点击下方「确认发起出差申请」即可继续;如果理解有误,请补充说明您的实际需求。'
].filter(Boolean).join('\n')
}
async function executeModelPlannedWorkbenchIntent(cleanPrompt, entry = {}, files = []) {
let intentPlan = null
let modelPlan = null
const plannerPendingMessage = startModelPlanningConversation(cleanPrompt, entry)
const stopPlanningProgressUpdates = startModelPlanningProgressUpdates(plannerPendingMessage.id)
sending.value = true
try {
modelPlan = await stewardFlow.resolveInlineExecutionPlan(cleanPrompt, entry, files, {
pendingMessageId: plannerPendingMessage.id
})
intentPlan = normalizeWorkbenchAiIntentPlan(modelPlan, { prompt: cleanPrompt })
} catch (error) {
console.warn('AI mode intent planner failed, using local fallback:', error)
const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan(cleanPrompt)
const ruleRequest = resolveExecutableTravelApplicationPlan(rulePlan)
if (ruleRequest) {
sending.value = false
startModelPlannedApplicationPreview(ruleRequest, plannerPendingMessage)
return
}
} finally {
stopPlanningProgressUpdates()
sending.value = false
}
const travelApplicationRequest = resolveExecutableTravelApplicationPlan(intentPlan)
if (travelApplicationRequest) {
if (isLowConfidenceTravelApplicationPlan(intentPlan)) {
startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, intentPlan, plannerPendingMessage)
return
}
startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage)
return
}
if (isModelPlannedReimbursementTask(modelPlan) || isReimbursementCreationIntent(cleanPrompt)) {
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', '已识别为报销任务,正在进入报销流程。', {
id: plannerPendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' }))
}
}))
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || cleanPrompt)
return
}
void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files, { pendingMessageId: plannerPendingMessage.id })
}
async 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()
const detailRequest = buildAiDocumentDetailRequest(detailReference)
if (!(await ensureAiDocumentDetailStillAvailable(detailRequest))) {
return
}
emit('open-document', detailRequest)
}
function startInlineConversation(prompt, entry = {}, files = []) {
if (!ensureAiModeCanStartConversation()) {
return
}
const cleanPrompt = buildInlinePromptText(prompt, files)
if (!cleanPrompt || sending.value) {
return
}
if (commandIntents.handleInlineDraftDeletionIntent(cleanPrompt, entry)) {
return
}
if (applicationFlow.handleInlineApplicationPreviewTextAction(cleanPrompt, applicationPreviewEstimatePending)) {
return
}
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
return
}
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
return
}
if (isReimbursementCreationIntent(cleanPrompt)) {
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || '我要报销')
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 (!ensureAiModeCanStartConversation()) {
return
}
if (!canSubmitAiModePrompt.value) {
toast('请输入需求后再发送。')
focusAiModeInput()
return
}
startInlineConversation(assistantDraft.value, { source: 'workbench', sessionType: 'steward' }, Array.from(selectedFiles.value))
}
function runAiModeAction(item) {
if (!ensureAiModeCanStartConversation()) {
return
}
if (String(item?.label || '').trim() === '发起报销') {
void expenseFlow.startAiReimbursementAssociationGate(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() {
return String([...conversationMessages.value].reverse().find((message) => message.role === 'user')?.content || '').trim()
}
function handleVoiceInput() {
if (!ensureAiModeCanStartConversation()) {
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 || {})
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
}
}
)
onMounted(() => {
loadSystemSettings()
refreshConversationHistory()
attachmentFlow.resumePendingAiAttachmentAssociationJobs()
expenseFlow.resumePendingLinkedReimbursementDraftJobs()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
})
return {
activeConversationTitle,
aiModeActionItems,
aiModeInputLockMessage,
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,
resolveInlineApplicationPreviewEditorDateMax: applicationFlow.resolveInlineApplicationPreviewEditorDateMax,
resolveInlineApplicationPreviewEditorDateMin: applicationFlow.resolveInlineApplicationPreviewEditorDateMin,
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
resolveInlineAttachmentOcrDocuments,
resolveInlineAttachmentOcrFileCount,
resolveInlineThinkingEvents,
runAiModeAction,
scrollInlineConversationToTop,
...filePreview,
sending,
setAssistantInputRef,
setWorkbenchDateMode,
submitAiModePrompt,
toggleInlineAttachmentOcrDetails,
toggleInlineThinking,
toggleWorkbenchDatePicker,
triggerAiModeFileUpload: filesFlow.triggerAiModeFileUpload,
workbenchCanApplyDateSelection,
workbenchDateMode,
workbenchDatePickerOpen,
workbenchDateTagLabel,
workbenchRangeEndDate,
workbenchRangeStartDate,
workbenchSingleDate,
applyWorkbenchDateSelection,
buildInlineApplicationPreviewFooterText: applicationFlow.buildInlineApplicationPreviewFooterText,
copyInlineMessage: messageActions.copyInlineMessage,
formatMessageTime,
handleWorkbenchDateInputChange
}
}