Files
X-Financial/web/src/views/scripts/TravelReimbursementCreateView.js

483 lines
14 KiB
JavaScript
Raw Normal View History

import { computed, nextTick, onMounted, ref } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { runOrchestrator } from '../../services/orchestrator.js'
const DEFAULT_REQUEST = {
id: 'BR240712001',
reason: '客户方案汇报',
city: '上海',
period: '07-08 ~ 07-11',
applyTime: '2024-07-07',
amount: '¥3,680.00',
node: '财务复核',
approval: '主管审批中',
travel: '已订酒店 / 机票'
}
const SOURCE_LABELS = {
workbench: '来自个人工作台',
topbar: '来自发起报销',
detail: '来自智能录入',
upload: '来自附件上传',
requests: '来自报销列表'
}
const SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
knowledge: '知识',
unknown: '通用'
}
const INTENT_LABELS = {
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '草稿生成',
operate: '动作请求'
}
let messageSeed = 0
function nowTime() {
return new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
function createMessage(role, text, attachments = [], extras = {}) {
messageSeed += 1
return {
id: `msg-${messageSeed}`,
role,
text,
attachments,
time: nowTime(),
meta: [],
citations: [],
suggestedActions: [],
draftPayload: null,
riskFlags: [],
...extras
}
}
function sanitizeRequest(request) {
if (!request) return { ...DEFAULT_REQUEST }
return {
id: request.id ?? DEFAULT_REQUEST.id,
reason: request.reason ?? DEFAULT_REQUEST.reason,
city: request.city ?? DEFAULT_REQUEST.city,
period: request.period ?? DEFAULT_REQUEST.period,
applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime,
amount: request.amount ?? DEFAULT_REQUEST.amount,
node: request.node ?? DEFAULT_REQUEST.node,
approval: request.approval ?? DEFAULT_REQUEST.approval,
travel: request.travel ?? DEFAULT_REQUEST.travel
}
}
function resolveStatusLabel(status) {
if (status === 'succeeded') return '已完成'
if (status === 'blocked') return '已阻断'
return '失败'
}
function resolveStatusTone(status) {
if (status === 'succeeded') return 'success'
if (status === 'blocked') return 'warning'
return 'note'
}
function buildMessageMeta(payload, fileNames = []) {
const items = []
if (payload?.selected_agent) {
items.push(`Agent: ${payload.selected_agent}`)
}
if (payload?.permission_level) {
items.push(`权限: ${payload.permission_level}`)
}
if (payload?.trace_summary?.tool_count) {
items.push(`工具: ${payload.trace_summary.tool_count}`)
}
if (payload?.trace_summary?.degraded) {
items.push('已降级')
}
if (payload?.requires_confirmation) {
items.push('待确认')
}
if (payload?.run_id) {
items.push(`Run: ${payload.run_id}`)
}
if (fileNames.length) {
items.push(`附件: ${fileNames.length}`)
}
return items
}
function buildWelcomeInsight(entrySource, linkedRequest) {
return {
intent: 'welcome',
metricLabel: '运行模式',
metricValue: 'Ready',
title: entrySource === 'detail' ? `已关联 ${linkedRequest.id}` : '已接入真实智能体对话',
summary:
entrySource === 'detail'
? '发送消息后会直接调用 Orchestrator并返回真实的规则引用、建议动作和草稿结果。'
: '这里不再使用前端本地意图模拟,所有发送内容都会进入真实 Orchestrator 调度链路。',
agent: null
}
}
function buildErrorInsight(error, fileNames = []) {
return {
intent: 'agent',
metricLabel: '运行状态',
metricValue: '失败',
title: '智能体调用失败',
summary: error?.message || '无法连接后端 Orchestrator。',
agent: {
runId: '未生成',
selectedAgent: 'orchestrator',
scenario: '未知',
intent: '未知',
permissionLevel: 'unknown',
routeReason: 'request_failed',
requiresConfirmation: false,
degraded: false,
fileNames,
citations: [],
suggestedActions: [],
draftPayload: null,
riskFlags: [],
toolCount: 0,
failedToolCount: 0,
selectedCapabilityCodes: [],
statusLabel: '失败',
statusTone: 'note'
}
}
}
function buildAgentInsight(payload, fileNames = []) {
const trace = payload?.trace_summary || {}
const result = payload?.result || {}
const statusLabel = resolveStatusLabel(payload?.status)
return {
intent: 'agent',
metricLabel: '运行状态',
metricValue: statusLabel,
title:
result?.draft_payload?.title ||
`${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`,
summary: result?.answer || result?.message || '智能体已完成处理。',
agent: {
runId: payload?.run_id || '未生成',
selectedAgent: payload?.selected_agent || 'orchestrator',
scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知',
intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知',
permissionLevel: payload?.permission_level || 'unknown',
routeReason: payload?.route_reason || 'unknown',
requiresConfirmation: Boolean(payload?.requires_confirmation),
degraded: Boolean(trace?.degraded),
fileNames,
citations: Array.isArray(result?.citations) ? result.citations : [],
suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [],
draftPayload: result?.draft_payload || null,
riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [],
toolCount: Number(trace?.tool_count || 0),
failedToolCount: Number(trace?.failed_tool_count || 0),
selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes)
? trace.selected_capability_codes
: [],
statusLabel,
statusTone: resolveStatusTone(payload?.status)
}
}
}
export default {
name: 'TravelReimbursementCreateView',
props: {
initialPrompt: {
type: String,
default: ''
},
initialFiles: {
type: Array,
default: () => []
},
entrySource: {
type: String,
default: 'requests'
},
requestContext: {
type: Object,
default: null
}
},
emits: ['close'],
setup(props, { emit }) {
const { currentUser } = useSystemState()
const fileInputRef = ref(null)
const messageListRef = ref(null)
const composerDraft = ref('')
const attachedFiles = ref([])
const submitting = ref(false)
const messages = ref([])
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const currentInsight = ref(buildWelcomeInsight(props.entrySource, linkedRequest.value))
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
const canSubmit = computed(
() => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length)
)
const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome')
const composerPlaceholder = computed(() => {
if (props.entrySource === 'detail') {
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
}
return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。'
})
const currentIntentLabel = computed(() => {
const labels = {
welcome: '等待输入',
agent: '真实智能体'
}
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
const shortcuts = computed(() => {
if (props.entrySource === 'detail') {
return [
{
label: '解释风险原因',
icon: 'mdi mdi-shield-alert-outline',
prompt: `解释一下 ${linkedRequest.value.id} 为什么会被拦截`
},
{
label: '生成处理意见',
icon: 'mdi mdi-file-document-edit-outline',
prompt: `帮我给 ${linkedRequest.value.id} 生成处理意见草稿`
},
{
label: '列出补件清单',
icon: 'mdi mdi-format-list-checks',
prompt: `帮我列出 ${linkedRequest.value.id} 还需要补哪些附件`
},
{
label: '引用相关制度',
icon: 'mdi mdi-book-open-variant-outline',
prompt: `解释一下 ${linkedRequest.value.id} 相关的报销制度依据`
}
]
}
return [
{
label: '查本周报销金额',
icon: 'mdi mdi-cash-multiple',
prompt: '查一下本周报销金额'
},
{
label: '解释报销风险',
icon: 'mdi mdi-shield-alert-outline',
prompt: '为什么酒店超标报销不能直接通过'
},
{
label: '生成报销草稿',
icon: 'mdi mdi-file-document-edit-outline',
prompt: '帮我生成一份差旅报销草稿'
},
{
label: '查待付款金额',
icon: 'mdi mdi-bank-transfer-out',
prompt: '供应商B待付款多少'
}
]
})
messages.value = [
createMessage(
'assistant',
props.entrySource === 'detail'
? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你的提问会直接进入真实智能体链路。`
: '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
)
]
onMounted(() => {
currentInsight.value = buildWelcomeInsight(props.entrySource, linkedRequest.value)
if (props.initialPrompt?.trim() || props.initialFiles.length) {
composerDraft.value = props.initialPrompt.trim()
attachedFiles.value = Array.from(props.initialFiles)
submitComposer()
} else {
nextTick(scrollToBottom)
}
})
function scrollToBottom() {
if (!messageListRef.value) return
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
function replaceMessage(messageId, nextMessage) {
const index = messages.value.findIndex((item) => item.id === messageId)
if (index === -1) {
messages.value.push(nextMessage)
return
}
messages.value.splice(index, 1, nextMessage)
}
function triggerFileUpload() {
if (submitting.value) return
fileInputRef.value?.click()
}
function handleFilesChange(event) {
attachedFiles.value = Array.from(event.target.files ?? [])
}
function runShortcut(prompt) {
composerDraft.value = prompt
submitComposer()
}
function buildBackendMessage(rawText, fileNames) {
const parts = []
const normalizedText = String(rawText || '').trim()
if (normalizedText) {
parts.push(normalizedText)
} else if (fileNames.length) {
parts.push(`我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。`)
}
if (fileNames.length) {
parts.push(`附件名称:${fileNames.join('、')}`)
}
if (props.entrySource === 'detail') {
parts.push(`关联单号:${linkedRequest.value.id}`)
}
return parts.join('\n')
}
async function submitComposer() {
if (!canSubmit.value) return
const rawText = composerDraft.value.trim()
const files = Array.from(attachedFiles.value)
const fileNames = files.map((file) => file.name)
const userText =
rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`
const backendMessage = buildBackendMessage(rawText, fileNames)
messages.value.push(createMessage('user', userText, fileNames))
const pendingMessage = createMessage('assistant', 'Orchestrator 正在处理中...', [], {
meta: ['运行中']
})
messages.value.push(pendingMessage)
composerDraft.value = ''
attachedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
submitting.value = true
nextTick(scrollToBottom)
try {
const user = currentUser.value || {}
const payload = await runOrchestrator({
source: 'user_message',
user_id: user.username || user.name || 'anonymous',
message: backendMessage,
context_json: {
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
is_admin: Boolean(user.isAdmin),
name: user.name || '',
role: user.role || '',
entry_source: props.entrySource,
request_context: linkedRequest.value,
attachment_names: fileNames,
attachment_count: fileNames.length
}
})
replaceMessage(
pendingMessage.id,
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
meta: buildMessageMeta(payload, fileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
draftPayload: payload?.result?.draft_payload || null,
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
})
)
currentInsight.value = buildAgentInsight(payload, fileNames)
} catch (error) {
replaceMessage(
pendingMessage.id,
createMessage(
'assistant',
error?.message || '无法连接后端 Orchestrator请稍后重试。',
[],
{
meta: ['调用失败']
}
)
)
currentInsight.value = buildErrorInsight(error, fileNames)
} finally {
submitting.value = false
nextTick(scrollToBottom)
}
}
return {
emit,
fileInputRef,
messageListRef,
composerDraft,
attachedFiles,
submitting,
messages,
currentInsight,
linkedRequest,
sourceLabel,
canSubmit,
showInsightPanel,
composerPlaceholder,
currentIntentLabel,
shortcuts,
triggerFileUpload,
handleFilesChange,
runShortcut,
submitComposer
}
}
}