feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -1,4 +1,4 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSystemState } from '../useSystemState.js'
import { useToast } from '../useToast.js'
import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
@@ -7,9 +7,7 @@ import { calculateTravelReimbursement, fetchExpenseClaimDetail } from '../../ser
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
import {
deleteAiWorkbenchConversation,
loadAiWorkbenchConversationHistory,
markAiWorkbenchConversationDocumentDeleted,
saveAiWorkbenchConversation
markAiWorkbenchConversationDocumentDeleted
} from '../../utils/aiWorkbenchConversationStore.js'
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
import {
@@ -41,23 +39,12 @@ import {
} from './workbenchAiApplicationGateModel.js'
import { useWorkbenchAiCommandIntents } from './useWorkbenchAiCommandIntents.js'
import {
buildRuleFallbackWorkbenchAiIntentPlan,
isLowConfidenceTravelApplicationPlan,
normalizeWorkbenchAiIntentPlan,
resolveExecutableTravelApplicationPlan,
shouldRequestWorkbenchAiIntentPlan
} from './workbenchAiIntentPlannerModel.js'
import {
buildInitialModelPlanningThinkingEvents,
buildModelPlanningProgressSchedule,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.js'
import { useWorkbenchAiConversationRuntime } from './useWorkbenchAiConversationRuntime.js'
import { useWorkbenchAiIntentExecution } from './useWorkbenchAiIntentExecution.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()
@@ -126,7 +113,47 @@ export function usePersonalWorkbenchAiMode(props, emit) {
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: () => focusAiModeInput() })
const conversationRuntime = useWorkbenchAiConversationRuntime({
activeConversationTitle,
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft,
assistantInputRef,
attachmentOcrExpandedMessageIds,
clearAiModeFiles: () => filesFlow.clearAiModeFiles(),
clearWorkbenchDateSelection,
conversationId,
conversationMessages,
conversationScrollRef,
conversationStarted,
currentUser,
deleteDialogOpen,
emit,
inlineConversationAutoScrollPinned,
searchConversationId: AI_SEARCH_CONVERSATION_ID,
serializeRuntimeMessage,
stewardState,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds
})
const {
activateInlineConversation,
appendInlineMessageContent,
focusAiModeInput,
handleInlineConversationScroll,
persistCurrentConversation,
refreshConversationHistory,
replaceInlineMessage,
resetInlineConversationState,
scrollInlineConversationToBottom,
scrollInlineConversationToTop,
setAssistantInputRef,
streamInlineAssistantContent,
streamOrSetInlineAssistantContent,
updateInlineMessageContent
} = conversationRuntime
const aiModeActionItems = AI_MODE_ACTION_ITEMS
const displayUserName = computed(() => {
@@ -225,6 +252,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
thinkingExpandedMessageIds
})
let intentExecution = null
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation,
applicationPreviewEditor,
@@ -259,7 +288,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
scrollInlineConversationToBottom,
sending,
toast,
onApplicationActionCompleted: startModelPlannedNextTask
onApplicationActionCompleted: (remainingTasks) => intentExecution?.startModelPlannedNextTask(remainingTasks)
})
const expenseFlow = useWorkbenchAiExpenseFlow({
@@ -354,6 +383,29 @@ export function usePersonalWorkbenchAiMode(props, emit) {
toast
})
intentExecution = useWorkbenchAiIntentExecution({
actionRouter,
activeConversationTitle,
activateInlineConversation,
applicationFlow,
assistantDraft,
clearAiModeFiles: filesFlow.clearAiModeFiles,
closeWorkbenchDatePicker,
conversationId,
conversationMessages,
createInlineMessage,
expenseFlow,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveInlineThinkingEvents,
scrollInlineConversationToBottom,
searchConversationId: AI_SEARCH_CONVERSATION_ID,
sending,
stewardFlow
})
const applicationPreviewEstimatePending = computed(() => (
conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
))
@@ -378,187 +430,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
}
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) {
@@ -648,238 +519,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
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 buildModelPlannedNextTaskAction(remainingTasks = []) {
const tasks = Array.isArray(remainingTasks) ? remainingTasks : []
const nextTask = tasks[0]
if (!nextTask || typeof nextTask !== 'object') {
return null
}
const taskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const assignedAgent = String(nextTask.assigned_agent || nextTask.assignedAgent || '').trim()
const isApplication = taskType === 'expense_application' || assignedAgent === 'application_assistant'
const isReimbursement = taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
if (!isApplication && !isReimbursement) {
return null
}
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销'
return {
label: `继续处理${taskLabel}`,
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: flowId,
steward_current_task: nextTask,
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields,
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
steward_remaining_tasks: tasks.slice(1)
}
}
}
function startModelPlannedNextTask(remainingTasks = []) {
const nextTaskAction = buildModelPlannedNextTaskAction(remainingTasks)
if (!nextTaskAction) {
return
}
actionRouter.handleInlineSuggestedAction(nextTaskAction)
}
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,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
onPreviewReadyForNextTask: startModelPlannedNextTask,
onApplicationActionCompleted: startModelPlannedNextTask
}
)
}
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,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks
}
}
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:"]')
@@ -923,7 +562,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
}
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
void intentExecution.executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
return
}

View File

@@ -0,0 +1,234 @@
import { nextTick } from 'vue'
import {
loadAiWorkbenchConversationHistory,
saveAiWorkbenchConversation
} from '../../utils/aiWorkbenchConversationStore.js'
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 useWorkbenchAiConversationRuntime(options) {
const {
activeConversationTitle,
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft,
assistantInputRef,
attachmentOcrExpandedMessageIds,
clearAiModeFiles,
clearWorkbenchDateSelection,
conversationId,
conversationMessages,
conversationScrollRef,
conversationStarted,
currentUser,
deleteDialogOpen,
emit,
inlineConversationAutoScrollPinned,
searchConversationId,
serializeRuntimeMessage,
stewardState,
thinkingCollapsedMessageIds,
thinkingExpandedMessageIds
} = options
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 !== searchConversationId &&
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()
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 })
}
return {
activateInlineConversation,
appendInlineMessageContent,
focusAiModeInput,
handleInlineConversationScroll,
persistCurrentConversation,
refreshConversationHistory,
replaceInlineMessage,
resetInlineConversationState,
scrollInlineConversationToBottom,
scrollInlineConversationToTop,
setAssistantInputRef,
streamInlineAssistantContent,
streamOrSetInlineAssistantContent,
updateInlineMessageContent
}
}

View File

@@ -0,0 +1,277 @@
import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js'
import {
buildRuleFallbackWorkbenchAiIntentPlan,
isLowConfidenceTravelApplicationPlan,
normalizeWorkbenchAiIntentPlan,
resolveExecutableTravelApplicationPlan
} from './workbenchAiIntentPlannerModel.js'
import {
buildInitialModelPlanningThinkingEvents,
buildModelPlanningProgressSchedule,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.js'
export function useWorkbenchAiIntentExecution(options) {
const {
actionRouter,
activeConversationTitle,
activateInlineConversation,
applicationFlow,
assistantDraft,
clearAiModeFiles,
closeWorkbenchDatePicker,
conversationId,
conversationMessages,
createInlineMessage,
expenseFlow,
inlineConversationAutoScrollPinned,
persistCurrentConversation,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveInlineThinkingEvents,
scrollInlineConversationToBottom,
searchConversationId,
sending,
stewardFlow
} = options
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 === searchConversationId) {
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()
clearAiModeFiles()
const pendingMessage = createInlineMessage('assistant', '正在识别意图,准备拆解申请、报销和附件任务。', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildInitialModelPlanningThinkingEvents()
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
persistCurrentConversation()
return pendingMessage
}
function buildModelPlannedNextTaskAction(remainingTasks = []) {
const tasks = Array.isArray(remainingTasks) ? remainingTasks : []
const nextTask = tasks[0]
if (!nextTask || typeof nextTask !== 'object') {
return null
}
const taskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const assignedAgent = String(nextTask.assigned_agent || nextTask.assignedAgent || '').trim()
const isApplication = taskType === 'expense_application' || assignedAgent === 'application_assistant'
const isReimbursement = taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
if (!isApplication && !isReimbursement) {
return null
}
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
const taskLabel = isApplication ? '出差申请' : '费用报销'
return {
label: `继续处理${taskLabel}`,
action_type: 'steward_continue_next_task',
payload: {
steward_confirm_flow: true,
flow_id: flowId,
steward_current_task: nextTask,
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
ontology_fields: ontologyFields,
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
steward_remaining_tasks: tasks.slice(1)
}
}
}
function startModelPlannedNextTask(remainingTasks = []) {
const nextTaskAction = buildModelPlannedNextTaskAction(remainingTasks)
if (nextTaskAction) {
actionRouter.handleInlineSuggestedAction(nextTaskAction)
}
}
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,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
onPreviewReadyForNextTask: startModelPlannedNextTask,
onApplicationActionCompleted: startModelPlannedNextTask
}
)
}
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')
}
function startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, plan, plannerPendingMessage) {
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,
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks
}
}
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage(
'assistant',
buildLowConfidenceTravelApplicationConfirmationText(travelApplicationRequest, plan),
{
id: plannerPendingMessage.id,
suggestedActions: [confirmAction],
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage)
.map((item) => ({ ...item, status: 'completed' }))
}
}
))
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
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 ruleRequest = resolveExecutableTravelApplicationPlan(
buildRuleFallbackWorkbenchAiIntentPlan(cleanPrompt)
)
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)
} else {
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
})
}
return {
executeModelPlannedWorkbenchIntent,
startModelPlannedNextTask
}
}