- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览 - 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示 - 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿 - PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善 - DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配 - 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
797 lines
27 KiB
JavaScript
797 lines
27 KiB
JavaScript
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,
|
||
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'
|
||
|
||
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 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,
|
||
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 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 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 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 (!ensureAiModeCanStartConversation()) {
|
||
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 (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
|
||
}
|
||
}
|