refactor: 重构 AuditView 和 TravelReimbursementCreateView 相关代码
- 优化 agent_assets、agent_foundation、user_agent 服务层结构 - 更新 AuditView 视图和脚本 - 更新 TravelReimbursementCreateView 脚本 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
import { renderMarkdown } from '../../utils/markdown.js'
|
||||
import {
|
||||
@@ -175,6 +176,36 @@ const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
const REVIEW_DRAWER_MODE_REVIEW = 'review'
|
||||
const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
|
||||
const REVIEW_DRAWER_MODE_RISK = 'risk'
|
||||
const FLOW_STEP_STATUS_PENDING = 'pending'
|
||||
const FLOW_STEP_STATUS_RUNNING = 'running'
|
||||
const FLOW_STEP_STATUS_COMPLETED = 'completed'
|
||||
const FLOW_STEP_STATUS_FAILED = 'failed'
|
||||
const FLOW_STEP_FALLBACKS = {
|
||||
intent: {
|
||||
title: '意图识别',
|
||||
tool: 'IntentRecognizer',
|
||||
runningText: '正在识别业务意图...',
|
||||
completedText: '意图识别完成'
|
||||
},
|
||||
extraction: {
|
||||
title: '信息提取',
|
||||
tool: 'SemanticExtractor',
|
||||
runningText: '正在提取时间、金额、费用类型和待补项...',
|
||||
completedText: '信息提取完成'
|
||||
},
|
||||
ocr: {
|
||||
title: '票据/OCR识别',
|
||||
tool: 'OCRService',
|
||||
runningText: '正在识别票据附件...',
|
||||
completedText: '票据识别完成'
|
||||
},
|
||||
result: {
|
||||
title: '生成结果',
|
||||
tool: 'ResultGenerator',
|
||||
runningText: '正在生成解释与草稿...',
|
||||
completedText: '结果已生成'
|
||||
}
|
||||
}
|
||||
const HOT_KNOWLEDGE_QUESTIONS = [
|
||||
'差旅住宿标准按什么规则执行?',
|
||||
'酒店超标后如何申请例外报销?',
|
||||
@@ -199,6 +230,23 @@ const CATEGORY_CONFIDENCE_KEYWORDS = {
|
||||
communication: [/通讯|电话|流量|话费|宽带|网络/],
|
||||
welfare: [/福利|体检|团建|节日|慰问|关怀/]
|
||||
}
|
||||
const FLOW_MISSING_SLOT_LABELS = {
|
||||
expense_type: '报销类型',
|
||||
customer_name: '客户名称',
|
||||
time_range: '发生时间',
|
||||
location: '地点',
|
||||
merchant_name: '酒店/商户',
|
||||
amount: '金额',
|
||||
reason: '事由说明',
|
||||
participants: '参与人员',
|
||||
attachments: '票据附件'
|
||||
}
|
||||
const FLOW_INTENT_KEYWORDS = {
|
||||
draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'],
|
||||
query: ['查询', '查一下', '多少', '明细', '统计'],
|
||||
risk_check: ['风险', '异常', '重复', '超标'],
|
||||
explain: ['为什么', '依据', '规则', '怎么']
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
|
||||
@@ -246,6 +294,297 @@ function formatMessageTime(value) {
|
||||
})
|
||||
}
|
||||
|
||||
function createFlowSteps() {
|
||||
return []
|
||||
}
|
||||
|
||||
function formatSemanticEntityValue(entity) {
|
||||
const normalizedValue = String(entity?.normalized_value || '').trim()
|
||||
const rawValue = String(entity?.value || '').trim()
|
||||
const entityType = String(entity?.type || '').trim()
|
||||
|
||||
if (entityType === 'amount') {
|
||||
const numericValue = Number(normalizedValue || rawValue)
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元`
|
||||
}
|
||||
}
|
||||
|
||||
return rawValue || normalizedValue
|
||||
}
|
||||
|
||||
function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
|
||||
if (!semanticParse || typeof semanticParse !== 'object') {
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : []
|
||||
const entityMap = new Map()
|
||||
for (const item of entities) {
|
||||
const entityType = String(item?.type || '').trim()
|
||||
if (!entityType || entityMap.has(entityType)) continue
|
||||
entityMap.set(entityType, item)
|
||||
}
|
||||
|
||||
const extractedParts = []
|
||||
const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object'
|
||||
? semanticParse.time_range_json
|
||||
: {}
|
||||
const startDate = String(timeRange.start_date || '').trim()
|
||||
const endDate = String(timeRange.end_date || '').trim()
|
||||
if (startDate) {
|
||||
extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`)
|
||||
}
|
||||
|
||||
const amountEntity = entityMap.get('amount')
|
||||
if (amountEntity) {
|
||||
const amountValue = formatSemanticEntityValue(amountEntity)
|
||||
if (amountValue) {
|
||||
extractedParts.push(`金额 ${amountValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
const expenseTypeEntity = entityMap.get('expense_type')
|
||||
if (expenseTypeEntity) {
|
||||
const expenseTypeLabel = resolveExpenseTypeLabel(
|
||||
String(expenseTypeEntity?.normalized_value || '').trim(),
|
||||
String(expenseTypeEntity?.value || '').trim()
|
||||
)
|
||||
if (expenseTypeLabel) {
|
||||
extractedParts.push(`费用类型 ${expenseTypeLabel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const customerEntity = entityMap.get('customer')
|
||||
if (customerEntity) {
|
||||
const customerValue = formatSemanticEntityValue(customerEntity)
|
||||
if (customerValue) {
|
||||
extractedParts.push(`客户 ${customerValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : []
|
||||
const missingLabels = missingSlots
|
||||
.map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (extractedParts.length && missingLabels.length) {
|
||||
return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}`
|
||||
}
|
||||
if (extractedParts.length) {
|
||||
return `已提取${extractedParts.join('、')}`
|
||||
}
|
||||
if (missingLabels.length) {
|
||||
return `已完成信息提取;待补充 ${missingLabels.join('、')}`
|
||||
}
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
function summarizeSemanticIntentDetail(semanticParse) {
|
||||
if (!semanticParse || typeof semanticParse !== 'object') {
|
||||
return FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}
|
||||
|
||||
const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
|
||||
const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
|
||||
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}`
|
||||
}
|
||||
|
||||
function extractLocalFlowCandidates(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = text.replace(/\s+/g, '')
|
||||
|
||||
let time = ''
|
||||
const explicitTimeMatch = text.match(/发生时间[::]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
|
||||
if (explicitTimeMatch?.[1]) {
|
||||
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
|
||||
} else {
|
||||
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
|
||||
if (dateMatch?.[1]) {
|
||||
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
|
||||
} else if (/今天|今日/.test(compact)) {
|
||||
time = '今天'
|
||||
} else if (/昨天|昨日/.test(compact)) {
|
||||
time = '昨天'
|
||||
} else if (/前天/.test(compact)) {
|
||||
time = '前天'
|
||||
}
|
||||
}
|
||||
|
||||
let amount = ''
|
||||
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
|
||||
if (amountMatch?.[1]) {
|
||||
const numericValue = Number(amountMatch[1])
|
||||
if (Number.isFinite(numericValue)) {
|
||||
amount = Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元`
|
||||
}
|
||||
}
|
||||
|
||||
let event = ''
|
||||
let expenseType = ''
|
||||
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
|
||||
event = '请客户吃饭'
|
||||
expenseType = '业务招待费'
|
||||
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
|
||||
event = '出差行程'
|
||||
expenseType = '差旅费'
|
||||
} else if (/打车|网约车|出租车|车费|停车/.test(compact)) {
|
||||
event = '交通出行'
|
||||
expenseType = '交通费'
|
||||
} else if (/住宿|酒店|宾馆/.test(compact)) {
|
||||
event = '住宿报销'
|
||||
expenseType = '住宿费'
|
||||
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
|
||||
event = '餐饮用餐'
|
||||
expenseType = '餐费'
|
||||
}
|
||||
|
||||
return {
|
||||
time,
|
||||
amount,
|
||||
event,
|
||||
expenseType
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) {
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return '初步识别为财务知识问答,正在准备检索范围'
|
||||
}
|
||||
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = text.replace(/\s+/g, '')
|
||||
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
|
||||
keywords.some((keyword) => compact.includes(keyword))
|
||||
)?.[0] || 'draft'
|
||||
const intentLabel = INTENT_LABELS[intentKey] || '处理'
|
||||
return `初步识别为报销场景,准备进入${intentLabel}`
|
||||
}
|
||||
|
||||
function buildLocalExtractionProgressMessages(rawText, options = {}) {
|
||||
const candidates = extractLocalFlowCandidates(rawText)
|
||||
const messages = []
|
||||
|
||||
messages.push('正在提取发生时间...')
|
||||
messages.push(
|
||||
candidates.time
|
||||
? `发现发生时间 ${candidates.time},继续提取金额...`
|
||||
: '暂未定位到明确时间,继续提取金额...'
|
||||
)
|
||||
messages.push(
|
||||
candidates.amount
|
||||
? `发现金额 ${candidates.amount},继续识别事件类型...`
|
||||
: '暂未定位到明确金额,继续识别事件类型...'
|
||||
)
|
||||
|
||||
if (candidates.event || candidates.expenseType) {
|
||||
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
|
||||
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
|
||||
} else {
|
||||
messages.push('正在识别事件类型和费用分类...')
|
||||
}
|
||||
|
||||
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
|
||||
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
function formatFlowDuration(ms) {
|
||||
const numericValue = Number(ms)
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return '--'
|
||||
}
|
||||
if (numericValue < 100) {
|
||||
return '<0.1s'
|
||||
}
|
||||
if (numericValue < 1000) {
|
||||
return `${(numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
if (numericValue < 10000) {
|
||||
return `${(numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
return `${Math.round(numericValue / 1000)}s`
|
||||
}
|
||||
|
||||
function parseFlowTimestamp(value) {
|
||||
const timestamp = new Date(value || '').getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
function resolveSemanticPhaseDurations(run) {
|
||||
const runStart = parseFlowTimestamp(run?.started_at)
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const firstToolStartedAt = toolCalls
|
||||
.map((item) => parseFlowTimestamp(item?.created_at))
|
||||
.filter((value) => value > 0)
|
||||
.sort((left, right) => left - right)[0] || 0
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
|
||||
|
||||
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
|
||||
return { intentMs: null, extractionMs: null }
|
||||
}
|
||||
|
||||
const totalMs = semanticFinishedAt - runStart
|
||||
const intentMs = Math.max(120, Math.round(totalMs * 0.35))
|
||||
const extractionMs = Math.max(160, totalMs - intentMs)
|
||||
return {
|
||||
intentMs,
|
||||
extractionMs
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
const explicitDuration = Number(toolCall?.duration_ms)
|
||||
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
|
||||
return explicitDuration
|
||||
}
|
||||
|
||||
const startedAt = parseFlowTimestamp(toolCall?.created_at)
|
||||
if (!startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at)
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
|
||||
|
||||
if (!finishedAt || finishedAt <= startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return finishedAt - startedAt
|
||||
}
|
||||
|
||||
function resolveResultStepDurationMs(run) {
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
if (!runFinishedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const semanticFinishedAt = (
|
||||
toolCalls
|
||||
.map((item, index) => {
|
||||
const startedAt = parseFlowTimestamp(item?.created_at)
|
||||
const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run)
|
||||
if (!startedAt || !durationMs) {
|
||||
return 0
|
||||
}
|
||||
return startedAt + durationMs
|
||||
})
|
||||
.filter((value) => value > 0)
|
||||
.sort((left, right) => right - left)[0]
|
||||
) || parseFlowTimestamp(run?.started_at)
|
||||
|
||||
if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runFinishedAt - semanticFinishedAt
|
||||
}
|
||||
|
||||
function sanitizeRequest(request) {
|
||||
if (!request || typeof request !== 'object') return null
|
||||
|
||||
@@ -994,6 +1333,13 @@ function formatDraftApplyTime(date = new Date()) {
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function formatDateInputValue(date = new Date()) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function buildDraftSavedPayload({
|
||||
draftPayload,
|
||||
reviewPayload,
|
||||
@@ -2173,9 +2519,15 @@ export default {
|
||||
const fileInputMode = ref('composer')
|
||||
const messageListRef = ref(null)
|
||||
const composerDraft = ref('')
|
||||
const composerDatePickerOpen = ref(false)
|
||||
const composerDateMode = ref('single')
|
||||
const composerSingleDate = ref(formatDateInputValue())
|
||||
const composerRangeStartDate = ref(formatDateInputValue())
|
||||
const composerRangeEndDate = ref(formatDateInputValue())
|
||||
const attachedFiles = ref([])
|
||||
const composerFilesExpanded = ref(false)
|
||||
const submitting = ref(false)
|
||||
const workbenchVisible = ref(false)
|
||||
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
|
||||
const initialSessionType = resolveInitialSessionType(props.initialConversation)
|
||||
const initialSessionState = props.initialConversation
|
||||
@@ -2222,10 +2574,61 @@ export default {
|
||||
url: ''
|
||||
})
|
||||
const sessionSwitchBusy = ref(false)
|
||||
const flowPanelOpen = ref(false)
|
||||
const flowRunId = ref('')
|
||||
const flowStartedAt = ref(0)
|
||||
const flowFinishedAt = ref(0)
|
||||
const flowSteps = ref(createFlowSteps())
|
||||
const flowRefreshBusy = ref(false)
|
||||
const flowTick = ref(Date.now())
|
||||
let flowTickTimer = 0
|
||||
const flowSimulationTimers = []
|
||||
const canSubmit = computed(
|
||||
() => !submitting.value && !sessionSwitchBusy.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length)
|
||||
)
|
||||
const composerCanApplyDateSelection = computed(() => {
|
||||
if (composerDateMode.value === 'single') {
|
||||
return Boolean(composerSingleDate.value)
|
||||
}
|
||||
return Boolean(
|
||||
composerRangeStartDate.value
|
||||
&& composerRangeEndDate.value
|
||||
&& composerRangeStartDate.value <= composerRangeEndDate.value
|
||||
)
|
||||
})
|
||||
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
||||
const completedFlowStepCount = computed(
|
||||
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
|
||||
)
|
||||
const runningFlowStep = computed(
|
||||
() => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null
|
||||
)
|
||||
const flowOverallStatusTone = computed(() => {
|
||||
if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
|
||||
return 'failed'
|
||||
}
|
||||
if (runningFlowStep.value) {
|
||||
return 'running'
|
||||
}
|
||||
if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) {
|
||||
return 'completed'
|
||||
}
|
||||
return 'pending'
|
||||
})
|
||||
const flowOverallStatusText = computed(() => {
|
||||
const total = flowSteps.value.length
|
||||
const completed = completedFlowStepCount.value
|
||||
if (flowOverallStatusTone.value === 'failed') {
|
||||
return `异常 ${completed}/${total}`
|
||||
}
|
||||
if (flowOverallStatusTone.value === 'completed') {
|
||||
return `已完成 ${total}/${total}`
|
||||
}
|
||||
if (flowOverallStatusTone.value === 'running') {
|
||||
return `执行中 ${completed}/${total}`
|
||||
}
|
||||
return total ? `待执行 0/${total}` : '暂无流程'
|
||||
})
|
||||
const hasInsightPanelContent = computed(
|
||||
() => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome'
|
||||
)
|
||||
@@ -2578,6 +2981,12 @@ export default {
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
flowTickTimer = window.setInterval(() => {
|
||||
flowTick.value = Date.now()
|
||||
}, 250)
|
||||
nextTick(() => {
|
||||
workbenchVisible.value = true
|
||||
})
|
||||
void clearKnowledgeSessionOnEntry()
|
||||
currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value)
|
||||
if (props.initialPrompt?.trim() || props.initialFiles.length) {
|
||||
@@ -2598,6 +3007,10 @@ export default {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (flowTickTimer) {
|
||||
window.clearInterval(flowTickTimer)
|
||||
}
|
||||
clearFlowSimulationTimers()
|
||||
for (const url of previewRegistry) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -2612,6 +3025,12 @@ export default {
|
||||
const emptyState = buildEmptySessionState(activeSessionType.value)
|
||||
sessionSnapshots.value[activeSessionType.value] = emptyState
|
||||
applySessionState(emptyState)
|
||||
clearFlowSimulationTimers()
|
||||
flowRunId.value = ''
|
||||
flowStartedAt.value = 0
|
||||
flowFinishedAt.value = 0
|
||||
flowSteps.value = createFlowSteps()
|
||||
flowPanelOpen.value = false
|
||||
}
|
||||
|
||||
function adjustComposerTextareaHeight() {
|
||||
@@ -2633,6 +3052,346 @@ export default {
|
||||
adjustComposerTextareaHeight()
|
||||
}
|
||||
|
||||
function handleComposerEnter(event) {
|
||||
if (event?.isComposing || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
|
||||
return
|
||||
}
|
||||
submitComposer()
|
||||
}
|
||||
|
||||
function toggleFlowPanel() {
|
||||
flowPanelOpen.value = !flowPanelOpen.value
|
||||
}
|
||||
|
||||
function openFlowPanel() {
|
||||
flowPanelOpen.value = true
|
||||
}
|
||||
|
||||
function clearFlowSimulationTimers() {
|
||||
while (flowSimulationTimers.length) {
|
||||
const timerId = flowSimulationTimers.pop()
|
||||
window.clearTimeout(timerId)
|
||||
window.clearInterval(timerId)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlowPanelAutoCollapse(delayMs = 1200) {
|
||||
const collapseTimer = window.setTimeout(() => {
|
||||
if (runningFlowStep.value || flowRefreshBusy.value || submitting.value) {
|
||||
return
|
||||
}
|
||||
if (flowSteps.value.length && !flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
|
||||
flowPanelOpen.value = false
|
||||
}
|
||||
}, delayMs)
|
||||
flowSimulationTimers.push(collapseTimer)
|
||||
}
|
||||
|
||||
function resetFlowRun() {
|
||||
clearFlowSimulationTimers()
|
||||
flowPanelOpen.value = true
|
||||
flowRunId.value = ''
|
||||
flowStartedAt.value = Date.now()
|
||||
flowFinishedAt.value = 0
|
||||
flowSteps.value = createFlowSteps()
|
||||
}
|
||||
|
||||
function findFlowDefinition(key) {
|
||||
return FLOW_STEP_FALLBACKS[key] || null
|
||||
}
|
||||
|
||||
function normalizeFlowStepPatch(key, patch = {}) {
|
||||
const definition = findFlowDefinition(key) || {}
|
||||
const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch }
|
||||
return {
|
||||
title: normalizedPatch.title || definition.title || '智能体工具调用',
|
||||
tool: normalizedPatch.tool || definition.tool || 'AgentTool',
|
||||
detail: normalizedPatch.detail || definition.runningText || '',
|
||||
...normalizedPatch
|
||||
}
|
||||
}
|
||||
|
||||
function createFlowStep(key, patch = {}) {
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
return {
|
||||
key,
|
||||
index: flowSteps.value.length + 1,
|
||||
title: normalizedPatch.title,
|
||||
tool: normalizedPatch.tool,
|
||||
status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING,
|
||||
detail: normalizedPatch.detail || '',
|
||||
durationMs: normalizedPatch.durationMs ?? null,
|
||||
startedAt: normalizedPatch.startedAt || 0,
|
||||
finishedAt: normalizedPatch.finishedAt || 0,
|
||||
error: normalizedPatch.error || ''
|
||||
}
|
||||
}
|
||||
|
||||
function upsertFlowStep(key, patch) {
|
||||
const existingStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (!existingStep) {
|
||||
flowSteps.value = [...flowSteps.value, createFlowStep(key, patch)]
|
||||
return
|
||||
}
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
flowSteps.value = flowSteps.value.map((step) => (
|
||||
step.key === key ? { ...step, ...normalizedPatch } : step
|
||||
))
|
||||
}
|
||||
|
||||
function startFlowStep(key, patch = {}) {
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
upsertFlowStep(key, {
|
||||
...normalizedPatch,
|
||||
status: FLOW_STEP_STATUS_RUNNING,
|
||||
detail: normalizedPatch.detail,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
})
|
||||
}
|
||||
|
||||
function completeFlowStep(key, detail = '', durationMs = null, patch = {}) {
|
||||
const now = Date.now()
|
||||
const definition = findFlowDefinition(key)
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const startedAt = currentStep?.startedAt || now
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || definition?.completedText || '',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt,
|
||||
error: ''
|
||||
})
|
||||
}
|
||||
|
||||
function failFlowStep(key, detail = '', error = '', patch = {}) {
|
||||
const now = Date.now()
|
||||
const definition = findFlowDefinition(key)
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const startedAt = currentStep?.startedAt || now
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_FAILED,
|
||||
detail: detail || error || '调用失败',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: now - startedAt,
|
||||
error: String(error || definition?.title || '').trim()
|
||||
})
|
||||
flowFinishedAt.value = now
|
||||
}
|
||||
|
||||
function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return
|
||||
}
|
||||
const normalizedDuration = Number(durationMs)
|
||||
const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0
|
||||
if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) {
|
||||
if (!hasMeasuredDuration && !currentStep?.startedAt) {
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || findFlowDefinition(key)?.completedText || '',
|
||||
startedAt: 0,
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
})
|
||||
return
|
||||
}
|
||||
startFlowStep(key, patch)
|
||||
}
|
||||
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch)
|
||||
}
|
||||
|
||||
function failCurrentFlowStep(error) {
|
||||
clearFlowSimulationTimers()
|
||||
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
|
||||
failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '')
|
||||
}
|
||||
|
||||
function startSemanticFlowPreview(rawText, options = {}) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value)
|
||||
const extractionMessages = buildLocalExtractionProgressMessages(rawText, options)
|
||||
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) {
|
||||
return
|
||||
}
|
||||
completePendingFlowStep('intent', intentPreview, null)
|
||||
}, 260)
|
||||
flowSimulationTimers.push(completeIntentTimer)
|
||||
|
||||
const startExtractionTimer = window.setTimeout(() => {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) {
|
||||
return
|
||||
}
|
||||
startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText)
|
||||
|
||||
if (extractionMessages.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
let index = 1
|
||||
const detailTimer = window.setInterval(() => {
|
||||
const runningStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) {
|
||||
window.clearInterval(detailTimer)
|
||||
return
|
||||
}
|
||||
upsertFlowStep('extraction', {
|
||||
detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1]
|
||||
})
|
||||
index = Math.min(index + 1, extractionMessages.length - 1)
|
||||
}, 650)
|
||||
flowSimulationTimers.push(detailTimer)
|
||||
}, 420)
|
||||
flowSimulationTimers.push(startExtractionTimer)
|
||||
}
|
||||
|
||||
function resolveToolCallFlowMeta(toolCall, index) {
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
||||
if (toolType.includes('rule')) {
|
||||
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
||||
}
|
||||
if (toolType.includes('mcp')) {
|
||||
return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' }
|
||||
}
|
||||
if (toolName.includes('knowledge')) {
|
||||
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
||||
}
|
||||
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
||||
return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (toolType.includes('database')) {
|
||||
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
|
||||
}
|
||||
if (toolType.includes('llm') || toolName.includes('user_agent')) {
|
||||
return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' }
|
||||
}
|
||||
return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' }
|
||||
}
|
||||
|
||||
function summarizeFlowToolCall(toolCall) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
return (
|
||||
String(response.message || response.summary || response.result_summary || '').trim()
|
||||
|| String(toolCall?.tool_name || '').trim()
|
||||
|| '工具调用完成'
|
||||
)
|
||||
}
|
||||
|
||||
function mergeFlowRunDetail(run) {
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
|
||||
clearFlowSimulationTimers()
|
||||
const semanticDurations = resolveSemanticPhaseDurations(run)
|
||||
completePendingFlowStep(
|
||||
'intent',
|
||||
summarizeSemanticIntentDetail(run.semantic_parse),
|
||||
semanticDurations.intentMs
|
||||
)
|
||||
completePendingFlowStep(
|
||||
'extraction',
|
||||
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
||||
semanticDurations.extractionMs
|
||||
)
|
||||
}
|
||||
|
||||
toolCalls.forEach((toolCall, index) => {
|
||||
const meta = resolveToolCallFlowMeta(toolCall, index)
|
||||
const failed = String(toolCall?.status || '').toLowerCase() === 'failed'
|
||||
if (failed) {
|
||||
failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta)
|
||||
} else {
|
||||
const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run)
|
||||
completePendingFlowStep(
|
||||
meta.key,
|
||||
summarizeFlowToolCall(toolCall),
|
||||
toolDurationMs,
|
||||
meta
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (String(run?.status || '').toLowerCase() === 'failed') {
|
||||
failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function completeFlowResult(payload, run = null) {
|
||||
const answer = String(payload?.result?.answer || payload?.result?.message || '').trim()
|
||||
if (!answer && !payload?.result) {
|
||||
return
|
||||
}
|
||||
startFlowStep('result', '正在返回处理结果...')
|
||||
completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run))
|
||||
flowFinishedAt.value = Date.now()
|
||||
scheduleFlowPanelAutoCollapse()
|
||||
}
|
||||
|
||||
async function refreshFlowRunDetail() {
|
||||
if (!flowRunId.value || flowRefreshBusy.value) {
|
||||
return null
|
||||
}
|
||||
flowRefreshBusy.value = true
|
||||
try {
|
||||
const run = await fetchAgentRunDetail(flowRunId.value)
|
||||
mergeFlowRunDetail(run)
|
||||
return run
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh agent run detail:', error)
|
||||
return null
|
||||
} finally {
|
||||
flowRefreshBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatFlowStepDuration(step) {
|
||||
if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) {
|
||||
return formatFlowDuration(flowTick.value - step.startedAt)
|
||||
}
|
||||
return formatFlowDuration(step?.durationMs)
|
||||
}
|
||||
|
||||
function buildComposerDateSelectionText() {
|
||||
if (composerDateMode.value === 'single') {
|
||||
return `发生时间:${composerSingleDate.value}`
|
||||
}
|
||||
if (composerRangeStartDate.value === composerRangeEndDate.value) {
|
||||
return `发生时间:${composerRangeStartDate.value}`
|
||||
}
|
||||
return `发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
}
|
||||
|
||||
async function applyComposerDateSelection() {
|
||||
if (!composerCanApplyDateSelection.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const dateText = buildComposerDateSelectionText()
|
||||
const currentDraft = composerDraft.value.trim()
|
||||
composerDraft.value = currentDraft ? `${currentDraft},${dateText}` : dateText
|
||||
composerDatePickerOpen.value = false
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
composerTextareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function rememberFilePreviews(filePreviews) {
|
||||
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
|
||||
}
|
||||
@@ -3102,6 +3861,10 @@ export default {
|
||||
}
|
||||
|
||||
function requestCloseWorkbench() {
|
||||
workbenchVisible.value = false
|
||||
}
|
||||
|
||||
function emitCloseAfterLeave() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -3285,6 +4048,12 @@ export default {
|
||||
return null
|
||||
}
|
||||
|
||||
resetFlowRun()
|
||||
if (rawText) {
|
||||
startFlowStep('intent', '正在识别业务意图...')
|
||||
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
|
||||
}
|
||||
|
||||
const fileNames = files.map((file) => file.name)
|
||||
const filePreviews = buildFilePreviews(files, previewRegistry)
|
||||
rememberFilePreviews(filePreviews)
|
||||
@@ -3334,14 +4103,17 @@ export default {
|
||||
let ocrFilePreviews = []
|
||||
|
||||
if (files.length) {
|
||||
startFlowStep('ocr', `正在识别 ${files.length} 份附件...`)
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files)
|
||||
ocrSummary = buildOcrSummary(ocrPayload)
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`)
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3396,6 +4168,11 @@ export default {
|
||||
: {}
|
||||
)
|
||||
responsePayload = payload
|
||||
flowRunId.value = String(payload?.run_id || '').trim()
|
||||
let flowRunDetail = null
|
||||
if (flowRunId.value) {
|
||||
flowRunDetail = await refreshFlowRunDetail()
|
||||
}
|
||||
|
||||
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
|
||||
draftClaimId.value =
|
||||
@@ -3432,7 +4209,10 @@ export default {
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
failCurrentFlowStep(error)
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage(
|
||||
@@ -3704,6 +4484,18 @@ export default {
|
||||
composerTextareaRef,
|
||||
messageListRef,
|
||||
composerDraft,
|
||||
composerDatePickerOpen,
|
||||
composerDateMode,
|
||||
composerSingleDate,
|
||||
composerRangeStartDate,
|
||||
composerRangeEndDate,
|
||||
composerCanApplyDateSelection,
|
||||
flowPanelOpen,
|
||||
flowSteps,
|
||||
flowRunId,
|
||||
flowRefreshBusy,
|
||||
flowOverallStatusTone,
|
||||
flowOverallStatusText,
|
||||
attachedFiles,
|
||||
composerFilesExpanded,
|
||||
visibleAttachedFiles,
|
||||
@@ -3755,6 +4547,7 @@ export default {
|
||||
REVIEW_SCENE_OTHER_OPTION,
|
||||
REVIEW_SCENE_OPTIONS,
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
workbenchVisible,
|
||||
reviewPanelConfidence,
|
||||
reviewRiskScore,
|
||||
reviewRiskSummary,
|
||||
@@ -3806,12 +4599,18 @@ export default {
|
||||
getExpenseQueryVisibleRecords,
|
||||
resolveDocumentPreview,
|
||||
triggerFileUpload,
|
||||
applyComposerDateSelection,
|
||||
handleFilesChange,
|
||||
handleComposerInput,
|
||||
handleComposerEnter,
|
||||
runShortcut,
|
||||
askHotKnowledgeQuestion,
|
||||
resolveKnowledgeRankLabel,
|
||||
resolveKnowledgeRankTone,
|
||||
toggleFlowPanel,
|
||||
openFlowPanel,
|
||||
refreshFlowRunDetail,
|
||||
formatFlowStepDuration,
|
||||
toggleInsightPanel,
|
||||
toggleReviewDocumentDrawer,
|
||||
toggleReviewRiskDrawer,
|
||||
@@ -3819,6 +4618,7 @@ export default {
|
||||
removeAttachedFile,
|
||||
clearAttachedFiles,
|
||||
requestCloseWorkbench,
|
||||
emitCloseAfterLeave,
|
||||
openExpenseQueryRecord,
|
||||
setExpenseQueryPage,
|
||||
shiftExpenseQueryPage,
|
||||
|
||||
Reference in New Issue
Block a user