2026-05-21 23:53:03 +08:00
|
|
|
|
import { computed, ref } from 'vue'
|
|
|
|
|
|
|
|
|
|
|
|
function formatFlowDuration(ms) {
|
|
|
|
|
|
const numericValue = Number(ms)
|
|
|
|
|
|
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
|
|
|
|
|
return '--'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (numericValue < 1000) {
|
|
|
|
|
|
return `${Math.max(0.1, 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function useTravelReimbursementFlow({
|
|
|
|
|
|
activeSessionType,
|
|
|
|
|
|
reviewDrawerMode,
|
|
|
|
|
|
insightPanelCollapsed,
|
|
|
|
|
|
isKnowledgeSession,
|
|
|
|
|
|
fetchAgentRunDetail,
|
|
|
|
|
|
buildLocalIntentPreview,
|
|
|
|
|
|
buildLocalExtractionProgressMessages,
|
|
|
|
|
|
summarizeSemanticIntentDetail,
|
|
|
|
|
|
summarizeSemanticParseDetail,
|
|
|
|
|
|
SCENARIO_LABELS,
|
|
|
|
|
|
INTENT_LABELS,
|
|
|
|
|
|
EXPENSE_TYPE_LABELS,
|
|
|
|
|
|
FLOW_STEP_FALLBACKS,
|
|
|
|
|
|
REVIEW_DRAWER_MODE_FLOW,
|
|
|
|
|
|
REVIEW_DRAWER_MODE_REVIEW,
|
|
|
|
|
|
FLOW_STEP_STATUS_PENDING,
|
|
|
|
|
|
FLOW_STEP_STATUS_RUNNING,
|
|
|
|
|
|
FLOW_STEP_STATUS_COMPLETED,
|
|
|
|
|
|
FLOW_STEP_STATUS_FAILED
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const flowRunId = ref('')
|
|
|
|
|
|
const flowStartedAt = ref(0)
|
|
|
|
|
|
const flowFinishedAt = ref(0)
|
|
|
|
|
|
const flowSteps = ref([])
|
|
|
|
|
|
const flowRefreshBusy = ref(false)
|
|
|
|
|
|
const flowTick = ref(Date.now())
|
|
|
|
|
|
let flowTickTimer = 0
|
|
|
|
|
|
const flowSimulationTimers = []
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
)
|
2026-05-27 10:32:08 +08:00
|
|
|
|
const visibleFlowSteps = computed(() => {
|
|
|
|
|
|
const visibleSteps = []
|
|
|
|
|
|
for (const step of flowSteps.value) {
|
|
|
|
|
|
visibleSteps.push(step)
|
|
|
|
|
|
if (step.status !== FLOW_STEP_STATUS_COMPLETED) {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return visibleSteps
|
|
|
|
|
|
})
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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 flowTotalDurationText = computed(() => {
|
|
|
|
|
|
if (!flowStartedAt.value) {
|
|
|
|
|
|
return '--'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const finishedAt = flowFinishedAt.value || (runningFlowStep.value ? flowTick.value : 0)
|
|
|
|
|
|
if (finishedAt > flowStartedAt.value) {
|
|
|
|
|
|
return formatFlowDuration(finishedAt - flowStartedAt.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const measuredDuration = flowSteps.value.reduce((total, step) => {
|
|
|
|
|
|
const duration = Number(step.durationMs)
|
|
|
|
|
|
return total + (Number.isFinite(duration) && duration > 0 ? duration : 0)
|
|
|
|
|
|
}, 0)
|
|
|
|
|
|
return measuredDuration ? formatFlowDuration(measuredDuration) : '--'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function startFlowTick() {
|
|
|
|
|
|
if (flowTickTimer) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
flowTickTimer = window.setInterval(() => {
|
|
|
|
|
|
flowTick.value = Date.now()
|
|
|
|
|
|
}, 250)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopFlowRuntime() {
|
|
|
|
|
|
if (flowTickTimer) {
|
|
|
|
|
|
window.clearInterval(flowTickTimer)
|
|
|
|
|
|
flowTickTimer = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
clearFlowSimulationTimers()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearFlowSimulationTimers() {
|
|
|
|
|
|
while (flowSimulationTimers.length) {
|
|
|
|
|
|
const timerId = flowSimulationTimers.pop()
|
|
|
|
|
|
window.clearTimeout(timerId)
|
|
|
|
|
|
window.clearInterval(timerId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetFlowRun(options = {}) {
|
|
|
|
|
|
clearFlowSimulationTimers()
|
|
|
|
|
|
const shouldOpenDrawer = options.openDrawer !== false
|
|
|
|
|
|
const startedAt = Number(options.startedAt)
|
|
|
|
|
|
flowRunId.value = ''
|
|
|
|
|
|
flowStartedAt.value = Number.isFinite(startedAt) && startedAt >= 0 ? startedAt : Date.now()
|
|
|
|
|
|
flowFinishedAt.value = 0
|
|
|
|
|
|
if (shouldOpenDrawer) {
|
|
|
|
|
|
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
|
|
|
|
|
insightPanelCollapsed.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
flowSteps.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-27 10:32:08 +08:00
|
|
|
|
error: normalizedPatch.error || '',
|
|
|
|
|
|
deferredCompletion: Boolean(normalizedPatch.deferredCompletion)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeFlowStepIndexes(steps) {
|
|
|
|
|
|
return steps.map((step, index) => ({ ...step, index: index + 1 }))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function upsertFlowStep(key, patch) {
|
|
|
|
|
|
const existingStep = flowSteps.value.find((step) => step.key === key)
|
|
|
|
|
|
if (!existingStep) {
|
|
|
|
|
|
const nextStep = createFlowStep(key, patch)
|
|
|
|
|
|
flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep])
|
|
|
|
|
|
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)
|
|
|
|
|
|
const explicitStartedAt = Number(normalizedPatch.startedAt)
|
|
|
|
|
|
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
|
|
|
|
|
? explicitStartedAt
|
|
|
|
|
|
: Date.now()
|
2026-05-22 23:47:28 +08:00
|
|
|
|
flowFinishedAt.value = 0
|
2026-05-21 23:53:03 +08:00
|
|
|
|
upsertFlowStep(key, {
|
|
|
|
|
|
...normalizedPatch,
|
|
|
|
|
|
status: FLOW_STEP_STATUS_RUNNING,
|
|
|
|
|
|
detail: normalizedPatch.detail,
|
|
|
|
|
|
startedAt,
|
|
|
|
|
|
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 explicitDuration = Number(durationMs)
|
|
|
|
|
|
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
|
|
|
|
|
|
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
|
|
|
|
|
|
upsertFlowStep(key, {
|
|
|
|
|
|
...patch,
|
|
|
|
|
|
status: FLOW_STEP_STATUS_COMPLETED,
|
|
|
|
|
|
detail: detail || definition?.completedText || '',
|
|
|
|
|
|
startedAt,
|
|
|
|
|
|
finishedAt: now,
|
|
|
|
|
|
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
|
2026-05-27 10:32:08 +08:00
|
|
|
|
error: '',
|
|
|
|
|
|
deferredCompletion: false
|
2026-05-21 23:53:03 +08:00
|
|
|
|
})
|
2026-05-27 10:32:08 +08:00
|
|
|
|
if (
|
|
|
|
|
|
flowSteps.value.length
|
|
|
|
|
|
&& flowSteps.value.every((step) => [FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
|
|
|
|
|
) {
|
|
|
|
|
|
flowFinishedAt.value = now
|
|
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-05-27 10:32:08 +08:00
|
|
|
|
const revealOrder = flowSteps.value.length
|
|
|
|
|
|
startFlowStep(key, { ...patch, deferredCompletion: true })
|
|
|
|
|
|
const completionTimer = window.setTimeout(() => {
|
|
|
|
|
|
completeFlowStep(
|
|
|
|
|
|
key,
|
|
|
|
|
|
detail || findFlowDefinition(key)?.completedText || '',
|
|
|
|
|
|
hasMeasuredDuration ? normalizedDuration : null,
|
|
|
|
|
|
{ deferredCompletion: false }
|
|
|
|
|
|
)
|
|
|
|
|
|
}, 280 + Math.min(revealOrder, 4) * 180)
|
|
|
|
|
|
flowSimulationTimers.push(completionTimer)
|
|
|
|
|
|
return
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 || 'orchestrator-error',
|
|
|
|
|
|
error?.message || '智能体调用失败',
|
|
|
|
|
|
error?.message || '',
|
|
|
|
|
|
currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' }
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startSemanticFlowPreview(rawText, options = {}) {
|
|
|
|
|
|
clearFlowSimulationTimers()
|
|
|
|
|
|
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
|
|
|
|
|
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 startExpenseSceneSelectionFlowPreview(rawText) {
|
|
|
|
|
|
clearFlowSimulationTimers()
|
|
|
|
|
|
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
|
|
|
|
|
const completeIntentTimer = window.setTimeout(() => {
|
|
|
|
|
|
completePendingFlowStep('intent', intentPreview, null)
|
|
|
|
|
|
}, 220)
|
|
|
|
|
|
flowSimulationTimers.push(completeIntentTimer)
|
|
|
|
|
|
|
|
|
|
|
|
const startSelectionTimer = window.setTimeout(() => {
|
|
|
|
|
|
startFlowStep('expense-scene-selection', {
|
|
|
|
|
|
detail: '报销意图已确认,但费用场景还不明确;暂停信息抽取,等待用户先选择报销场景。'
|
|
|
|
|
|
})
|
|
|
|
|
|
}, 320)
|
|
|
|
|
|
flowSimulationTimers.push(startSelectionTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startExpenseIntentConfirmationFlowPreview(rawText) {
|
|
|
|
|
|
clearFlowSimulationTimers()
|
|
|
|
|
|
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
|
|
|
|
|
const completeIntentTimer = window.setTimeout(() => {
|
|
|
|
|
|
completePendingFlowStep('intent', intentPreview, null)
|
|
|
|
|
|
}, 220)
|
|
|
|
|
|
flowSimulationTimers.push(completeIntentTimer)
|
|
|
|
|
|
|
|
|
|
|
|
const startConfirmationTimer = window.setTimeout(() => {
|
|
|
|
|
|
startFlowStep('expense-intent-confirmation', {
|
|
|
|
|
|
detail: '识别到业务事项描述,但是否发起报销还不明确;暂停信息抽取,等待用户确认。'
|
|
|
|
|
|
})
|
|
|
|
|
|
}, 320)
|
|
|
|
|
|
flowSimulationTimers.push(startConfirmationTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startExpenseSceneSelectionAfterIntentConfirmation(rawText) {
|
|
|
|
|
|
clearFlowSimulationTimers()
|
|
|
|
|
|
completePendingFlowStep('expense-intent-confirmation', '用户已确认要发起报销', null)
|
|
|
|
|
|
startFlowStep('expense-scene-selection', {
|
|
|
|
|
|
detail: '报销意图已确认,但费用场景还不明确;暂停信息抽取,等待用户先选择报销场景。'
|
|
|
|
|
|
})
|
|
|
|
|
|
if (reviewDrawerMode.value !== REVIEW_DRAWER_MODE_FLOW) {
|
|
|
|
|
|
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isExpenseSceneSelectionResult(payload) {
|
|
|
|
|
|
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
|
|
|
|
|
if (result.review_payload) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return (Array.isArray(result.suggested_actions) ? result.suggested_actions : []).some(
|
|
|
|
|
|
(item) => String(item?.action_type || '').trim() === 'select_expense_type'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startReviewActionFlowStep(reviewAction) {
|
|
|
|
|
|
if (reviewAction !== 'next_step') {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
startFlowStep('pre-submit-review', {
|
|
|
|
|
|
title: 'AI预审与风险识别',
|
|
|
|
|
|
tool: 'ExpenseClaimService.submit_claim',
|
|
|
|
|
|
detail: '正在校验财务规则、风险规则和审批路径...'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startExpenseClaimDraftFlowStep(reviewAction, options = {}) {
|
|
|
|
|
|
if (isKnowledgeSession.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (options.waitForSceneSelection) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (reviewAction === 'next_step') {
|
|
|
|
|
|
startReviewActionFlowStep(reviewAction)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const attachmentCount = Math.max(0, Number(options.attachmentCount || 0))
|
|
|
|
|
|
const configs = {
|
|
|
|
|
|
save_draft: {
|
|
|
|
|
|
key: 'expense-claim-draft',
|
|
|
|
|
|
title: '保存报销草稿',
|
|
|
|
|
|
tool: 'database.expense_claims.save_or_submit',
|
|
|
|
|
|
detail: '正在把已确认信息保存为草稿...'
|
|
|
|
|
|
},
|
|
|
|
|
|
link_to_existing_draft: {
|
2026-05-22 23:47:28 +08:00
|
|
|
|
key: 'attachment-association',
|
2026-05-21 23:53:03 +08:00
|
|
|
|
title: '票据关联草稿',
|
|
|
|
|
|
tool: 'database.expense_claims.save_or_submit',
|
|
|
|
|
|
detail: '正在把本次票据关联到现有草稿...'
|
|
|
|
|
|
},
|
|
|
|
|
|
create_new_claim_from_documents: {
|
|
|
|
|
|
key: 'expense-claim-draft',
|
|
|
|
|
|
title: '新建报销草稿',
|
|
|
|
|
|
tool: 'database.expense_claims.save_or_submit',
|
|
|
|
|
|
detail: '正在根据当前票据新建报销草稿...'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const defaultConfigBySessionType = {
|
|
|
|
|
|
application: {
|
|
|
|
|
|
key: 'application-review-preview',
|
|
|
|
|
|
title: '申请信息核对',
|
|
|
|
|
|
tool: 'user_agent.application_review_preview',
|
|
|
|
|
|
detail: '正在整理申请事项和待补充信息...'
|
|
|
|
|
|
},
|
|
|
|
|
|
approval: {
|
|
|
|
|
|
key: 'approval-review-preview',
|
|
|
|
|
|
title: '审核信息核对',
|
|
|
|
|
|
tool: 'user_agent.approval_review_preview',
|
|
|
|
|
|
detail: '正在整理待审核单据、风险点和审核建议...'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const config = configs[reviewAction] || defaultConfigBySessionType[String(activeSessionType.value || '').trim()] || {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
key: 'expense-review-preview',
|
|
|
|
|
|
title: '报销信息核对',
|
|
|
|
|
|
tool: 'user_agent.expense_review_preview',
|
|
|
|
|
|
detail: attachmentCount
|
|
|
|
|
|
? '正在根据 OCR 结果整理核对信息...'
|
|
|
|
|
|
: '正在整理识别结果和右侧核对信息...'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
startFlowStep(config.key, {
|
|
|
|
|
|
title: config.title,
|
|
|
|
|
|
tool: config.tool,
|
|
|
|
|
|
detail: config.detail
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveToolCallFlowMeta(toolCall, index) {
|
|
|
|
|
|
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
|
|
|
|
|
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
|
|
|
|
|
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
|
|
|
|
|
? toolCall.response_json
|
|
|
|
|
|
: {}
|
|
|
|
|
|
const responseMessage = String(response.message || '').trim()
|
|
|
|
|
|
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_review_preview') || response.preview_only) {
|
|
|
|
|
|
return { key: 'expense-review-preview', title: '报销信息核对', tool: toolCall?.tool_name || 'user_agent.expense_review_preview' }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
|
|
|
|
|
if (
|
|
|
|
|
|
response.submission_blocked ||
|
|
|
|
|
|
String(response.status || '').trim() === 'submitted' ||
|
|
|
|
|
|
responseMessage.includes('AI预审') ||
|
|
|
|
|
|
responseMessage.includes('审批')
|
|
|
|
|
|
) {
|
|
|
|
|
|
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (responseMessage.includes('关联')) {
|
2026-05-22 23:47:28 +08:00
|
|
|
|
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (responseMessage.includes('新建')) {
|
|
|
|
|
|
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
|
|
|
|
|
}
|
|
|
|
|
|
return { key: 'expense-claim-draft', 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
|
|
|
|
|
|
: {}
|
|
|
|
|
|
if (String(response.status || '').trim() === 'submitted') {
|
|
|
|
|
|
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (response.submission_blocked) {
|
|
|
|
|
|
return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批'
|
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
|
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
|
|
|
|
|
|
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
|
|
|
|
|
completePendingFlowStep(
|
|
|
|
|
|
'intent',
|
|
|
|
|
|
summarizeSemanticIntentDetail(run.semantic_parse, {
|
|
|
|
|
|
scenarioLabels: SCENARIO_LABELS,
|
|
|
|
|
|
intentLabels: INTENT_LABELS,
|
|
|
|
|
|
expenseTypeLabels: EXPENSE_TYPE_LABELS,
|
|
|
|
|
|
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
|
|
|
|
|
|
}),
|
|
|
|
|
|
intentStep?.startedAt ? null : semanticDurations.intentMs
|
|
|
|
|
|
)
|
|
|
|
|
|
completePendingFlowStep(
|
|
|
|
|
|
'extraction',
|
|
|
|
|
|
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
|
|
|
|
|
extractionStep?.startedAt ? null : 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
|
|
|
|
|
|
}
|
|
|
|
|
|
const sceneSelectionPending = isExpenseSceneSelectionResult(payload)
|
|
|
|
|
|
flowSteps.value
|
|
|
|
|
|
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
2026-05-27 10:32:08 +08:00
|
|
|
|
.filter((step) => !step.deferredCompletion)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
.forEach((step) => {
|
|
|
|
|
|
const detail = sceneSelectionPending && step.key === 'expense-scene-selection'
|
|
|
|
|
|
? '已暂停后续识别,请先在主对话中选择报销场景。'
|
|
|
|
|
|
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
|
|
|
|
|
|
completeFlowStep(step.key, detail)
|
|
|
|
|
|
})
|
2026-05-27 10:32:08 +08:00
|
|
|
|
flowFinishedAt.value = flowSteps.value.some(
|
|
|
|
|
|
(step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)
|
|
|
|
|
|
)
|
|
|
|
|
|
? 0
|
|
|
|
|
|
: Date.now()
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 resolveFlowStepStatusLabel(step) {
|
|
|
|
|
|
const status = String(step?.status || '').trim()
|
|
|
|
|
|
if (status === FLOW_STEP_STATUS_COMPLETED) {
|
|
|
|
|
|
return '完成'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (status === FLOW_STEP_STATUS_RUNNING) {
|
|
|
|
|
|
return '执行中'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (status === FLOW_STEP_STATUS_FAILED) {
|
|
|
|
|
|
return '异常'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '待执行'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveFlowStepDetail(step) {
|
|
|
|
|
|
const detail = String(step?.detail || '').trim()
|
|
|
|
|
|
if (detail) {
|
|
|
|
|
|
return detail
|
|
|
|
|
|
}
|
|
|
|
|
|
const definition = findFlowDefinition(step?.key)
|
|
|
|
|
|
if (step?.status === FLOW_STEP_STATUS_COMPLETED) {
|
|
|
|
|
|
return definition?.completedText || '步骤已完成'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (step?.status === FLOW_STEP_STATUS_RUNNING) {
|
|
|
|
|
|
return definition?.runningText || '正在执行当前步骤...'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (step?.status === FLOW_STEP_STATUS_FAILED) {
|
|
|
|
|
|
return step?.error || '步骤执行异常'
|
|
|
|
|
|
}
|
|
|
|
|
|
return definition?.runningText ? `等待${definition.title || '当前步骤'}...` : '等待智能体调度...'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
flowRunId,
|
|
|
|
|
|
flowStartedAt,
|
|
|
|
|
|
flowFinishedAt,
|
|
|
|
|
|
flowSteps,
|
2026-05-27 10:32:08 +08:00
|
|
|
|
visibleFlowSteps,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
flowRefreshBusy,
|
|
|
|
|
|
flowTick,
|
|
|
|
|
|
completedFlowStepCount,
|
|
|
|
|
|
runningFlowStep,
|
|
|
|
|
|
flowOverallStatusTone,
|
|
|
|
|
|
flowOverallStatusText,
|
|
|
|
|
|
flowTotalDurationText,
|
|
|
|
|
|
clearFlowSimulationTimers,
|
|
|
|
|
|
resetFlowRun,
|
|
|
|
|
|
findFlowDefinition,
|
|
|
|
|
|
normalizeFlowStepPatch,
|
|
|
|
|
|
createFlowStep,
|
|
|
|
|
|
normalizeFlowStepIndexes,
|
|
|
|
|
|
upsertFlowStep,
|
|
|
|
|
|
startFlowTick,
|
|
|
|
|
|
stopFlowRuntime,
|
|
|
|
|
|
startFlowStep,
|
|
|
|
|
|
completeFlowStep,
|
|
|
|
|
|
failFlowStep,
|
|
|
|
|
|
completePendingFlowStep,
|
|
|
|
|
|
failCurrentFlowStep,
|
|
|
|
|
|
startSemanticFlowPreview,
|
|
|
|
|
|
startExpenseSceneSelectionFlowPreview,
|
|
|
|
|
|
startExpenseIntentConfirmationFlowPreview,
|
|
|
|
|
|
startExpenseSceneSelectionAfterIntentConfirmation,
|
|
|
|
|
|
isExpenseSceneSelectionResult,
|
|
|
|
|
|
startReviewActionFlowStep,
|
|
|
|
|
|
startExpenseClaimDraftFlowStep,
|
|
|
|
|
|
resolveToolCallFlowMeta,
|
|
|
|
|
|
summarizeFlowToolCall,
|
|
|
|
|
|
mergeFlowRunDetail,
|
|
|
|
|
|
completeFlowResult,
|
|
|
|
|
|
refreshFlowRunDetail,
|
|
|
|
|
|
formatFlowStepDuration,
|
|
|
|
|
|
resolveFlowStepStatusLabel,
|
|
|
|
|
|
resolveFlowStepDetail
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|