feat(web): update Vue components and composables
- PersonalWorkbench.vue: update personal workbench component - useAppShell.js: update app shell composable - useChat.js: update chat composable with new features - AppShellRouteView.vue: update route view - ChatView.vue: update chat view with enhanced UI - TravelReimbursementCreateView.vue: update travel reimbursement form - ChatView.js: update chat view script logic - TravelReimbursementCreateView.js: update travel form script logic
This commit is contained in:
@@ -19,16 +19,24 @@
|
|||||||
<p>自动识别报销类别、核对附件完整性,并生成可继续提交的报销草稿。</p>
|
<p>自动识别报销类别、核对附件完整性,并生成可继续提交的报销草稿。</p>
|
||||||
|
|
||||||
<div class="assistant-input">
|
<div class="assistant-input">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
class="assistant-file-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
|
||||||
|
@change="handleWorkbenchFilesChange"
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="assistantDraft"
|
v-model="assistantDraft"
|
||||||
rows="1"
|
rows="1"
|
||||||
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
|
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
|
||||||
@keydown.ctrl.enter.prevent="openAssistantWithDraft"
|
@keydown.enter.prevent="handleWorkbenchEnter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="assistant-tools">
|
<div class="assistant-tools">
|
||||||
<button type="button" class="ghost-action" @click="emit('openAssistant', { prompt: '', source: 'upload' })">
|
<button type="button" class="ghost-action" @click="triggerFileUpload">
|
||||||
<i class="mdi mdi-upload-outline"></i>
|
<i class="mdi mdi-upload-outline"></i>
|
||||||
<span>上传票据</span>
|
<span>上传票据</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -130,6 +138,7 @@ defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['openAssistant'])
|
const emit = defineEmits(['openAssistant'])
|
||||||
const assistantDraft = ref('')
|
const assistantDraft = ref('')
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
|
||||||
function openAssistantWithDraft() {
|
function openAssistantWithDraft() {
|
||||||
emit('openAssistant', {
|
emit('openAssistant', {
|
||||||
@@ -138,6 +147,31 @@ function openAssistantWithDraft() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWorkbenchEnter(event) {
|
||||||
|
if (event.isComposing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openAssistantWithDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileUpload() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkbenchFilesChange(event) {
|
||||||
|
const files = Array.from(event.target.files ?? [])
|
||||||
|
if (!files.length) return
|
||||||
|
|
||||||
|
emit('openAssistant', {
|
||||||
|
prompt: assistantDraft.value.trim(),
|
||||||
|
source: 'upload',
|
||||||
|
files
|
||||||
|
})
|
||||||
|
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
const todoItems = [
|
const todoItems = [
|
||||||
{
|
{
|
||||||
title: '业务招待报销建议补参与人员',
|
title: '业务招待报销建议补参与人员',
|
||||||
@@ -371,6 +405,10 @@ const policyItems = [
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-input textarea {
|
.assistant-input textarea {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -14,13 +14,25 @@ export function useAppShell() {
|
|||||||
|
|
||||||
const travelCreateMode = ref(false)
|
const travelCreateMode = ref(false)
|
||||||
const smartEntryOpen = ref(false)
|
const smartEntryOpen = ref(false)
|
||||||
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
|
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [] })
|
||||||
const smartEntrySessionId = ref(0)
|
const smartEntrySessionId = ref(0)
|
||||||
|
|
||||||
const { activeView, currentView, setView } = useNavigation()
|
const { activeView, currentView, setView } = useNavigation()
|
||||||
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } =
|
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } =
|
||||||
useRequests()
|
useRequests()
|
||||||
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } =
|
const {
|
||||||
|
messages,
|
||||||
|
draft,
|
||||||
|
uploadedFiles,
|
||||||
|
messageList,
|
||||||
|
activeCase,
|
||||||
|
prompts,
|
||||||
|
sending,
|
||||||
|
sendMessage,
|
||||||
|
handleUpload,
|
||||||
|
openChat,
|
||||||
|
openNewChat
|
||||||
|
} =
|
||||||
useChat(activeView)
|
useChat(activeView)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
@@ -81,7 +93,7 @@ export function useAppShell() {
|
|||||||
function openTravelCreate() {
|
function openTravelCreate() {
|
||||||
smartEntryOpen.value = true
|
smartEntryOpen.value = true
|
||||||
travelCreateMode.value = false
|
travelCreateMode.value = false
|
||||||
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
|
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [] }
|
||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +104,8 @@ export function useAppShell() {
|
|||||||
smartEntryContext.value = {
|
smartEntryContext.value = {
|
||||||
prompt: payload.prompt ?? '',
|
prompt: payload.prompt ?? '',
|
||||||
source: payload.source ?? 'workbench',
|
source: payload.source ?? 'workbench',
|
||||||
request: payload.request ?? selectedTravelRequest.value
|
request: payload.request ?? selectedTravelRequest.value,
|
||||||
|
files: Array.isArray(payload.files) ? payload.files : []
|
||||||
}
|
}
|
||||||
smartEntrySessionId.value += 1
|
smartEntrySessionId.value += 1
|
||||||
}
|
}
|
||||||
@@ -145,6 +158,7 @@ export function useAppShell() {
|
|||||||
search,
|
search,
|
||||||
selectedTravelRequest,
|
selectedTravelRequest,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sending,
|
||||||
setView,
|
setView,
|
||||||
smartEntryContext,
|
smartEntryContext,
|
||||||
smartEntryOpen,
|
smartEntryOpen,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { nextTick, ref } from 'vue'
|
import { nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useSystemState } from './useSystemState.js'
|
||||||
|
import { runOrchestrator } from '../services/orchestrator.js'
|
||||||
|
|
||||||
const initialMessages = [
|
const initialMessages = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -21,11 +24,13 @@ const initialMessages = [
|
|||||||
export const prompts = ['生成审批意见', '列出补件清单', '解释风险原因', '生成运营简报']
|
export const prompts = ['生成审批意见', '列出补件清单', '解释风险原因', '生成运营简报']
|
||||||
|
|
||||||
export function useChat(activeView) {
|
export function useChat(activeView) {
|
||||||
|
const { currentUser } = useSystemState()
|
||||||
const messages = ref([...initialMessages])
|
const messages = ref([...initialMessages])
|
||||||
const draft = ref('')
|
const draft = ref('')
|
||||||
const uploadedFiles = ref([])
|
const uploadedFiles = ref([])
|
||||||
const messageList = ref(null)
|
const messageList = ref(null)
|
||||||
const activeCase = ref(null)
|
const activeCase = ref(null)
|
||||||
|
const sending = ref(false)
|
||||||
|
|
||||||
function agentReply(text) {
|
function agentReply(text) {
|
||||||
const c = activeCase.value
|
const c = activeCase.value
|
||||||
@@ -56,15 +61,113 @@ export function useChat(activeView) {
|
|||||||
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight, behavior: 'smooth' }))
|
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight, behavior: 'smooth' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMessage() {
|
function replaceMessage(id, nextMessage) {
|
||||||
|
const index = messages.value.findIndex((item) => item.id === id)
|
||||||
|
if (index === -1) {
|
||||||
|
messages.value.push(nextMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messages.value.splice(index, 1, nextMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrchestratorMeta(payload) {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssistantMessage(payload, fallbackText) {
|
||||||
|
const result = payload?.result || {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: result.answer || result.message || fallbackText,
|
||||||
|
meta: buildOrchestratorMeta(payload),
|
||||||
|
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 : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
const text = draft.value.trim()
|
const text = draft.value.trim()
|
||||||
if (!text) return false
|
if (!text || sending.value) return false
|
||||||
messages.value.push({ id: Date.now(), role: 'user', text })
|
|
||||||
|
const userMessageId = Date.now()
|
||||||
|
const pendingMessageId = userMessageId + 1
|
||||||
|
messages.value.push({ id: userMessageId, role: 'user', text })
|
||||||
draft.value = ''
|
draft.value = ''
|
||||||
setTimeout(() => {
|
sending.value = true
|
||||||
messages.value.push({ id: Date.now() + 1, role: 'agent', text: agentReply(text) })
|
messages.value.push({
|
||||||
|
id: pendingMessageId,
|
||||||
|
role: 'agent',
|
||||||
|
text: 'Orchestrator 正在处理中...',
|
||||||
|
meta: ['运行中']
|
||||||
|
})
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}, 260)
|
|
||||||
|
const user = currentUser.value || {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await runOrchestrator({
|
||||||
|
source: 'user_message',
|
||||||
|
user_id: user.username || user.name || 'anonymous',
|
||||||
|
message: text,
|
||||||
|
context_json: {
|
||||||
|
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
|
||||||
|
is_admin: Boolean(user.isAdmin),
|
||||||
|
name: user.name || '',
|
||||||
|
role: user.role || '',
|
||||||
|
active_case_id: activeCase.value?.id || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const assistantMessage = buildAssistantMessage(payload, 'Orchestrator 已完成处理。')
|
||||||
|
replaceMessage(pendingMessageId, {
|
||||||
|
id: pendingMessageId,
|
||||||
|
role: 'agent',
|
||||||
|
...assistantMessage
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
replaceMessage(pendingMessageId, {
|
||||||
|
id: pendingMessageId,
|
||||||
|
role: 'agent',
|
||||||
|
text: `后端暂时不可用,已切换为本地占位回复:${agentReply(text)}`,
|
||||||
|
meta: [error?.message || '后端调用失败'],
|
||||||
|
citations: [],
|
||||||
|
suggestedActions: [],
|
||||||
|
draftPayload: null,
|
||||||
|
riskFlags: []
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +199,7 @@ export function useChat(activeView) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages, draft, uploadedFiles, messageList, activeCase, prompts,
|
messages, draft, uploadedFiles, messageList, activeCase, prompts, sending,
|
||||||
sendMessage, handleUpload, openChat, openNewChat, scrollToBottom
|
sendMessage, handleUpload, openChat, openNewChat, scrollToBottom
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
:active-case="activeCase"
|
:active-case="activeCase"
|
||||||
:quick-prompts="travelPrompts"
|
:quick-prompts="travelPrompts"
|
||||||
:draft="draft"
|
:draft="draft"
|
||||||
|
:sending="sending"
|
||||||
:message-list="messageList"
|
:message-list="messageList"
|
||||||
@send="sendMessage"
|
@send="sendMessage"
|
||||||
@upload="handleUpload"
|
@upload="handleUpload"
|
||||||
@@ -123,6 +124,7 @@
|
|||||||
v-if="smartEntryOpen"
|
v-if="smartEntryOpen"
|
||||||
:key="smartEntrySessionId"
|
:key="smartEntrySessionId"
|
||||||
:initial-prompt="smartEntryContext.prompt"
|
:initial-prompt="smartEntryContext.prompt"
|
||||||
|
:initial-files="smartEntryContext.files"
|
||||||
:entry-source="smartEntryContext.source"
|
:entry-source="smartEntryContext.source"
|
||||||
:request-context="smartEntryContext.request"
|
:request-context="smartEntryContext.request"
|
||||||
@close="closeSmartEntry"
|
@close="closeSmartEntry"
|
||||||
@@ -184,6 +186,7 @@ const {
|
|||||||
search,
|
search,
|
||||||
selectedTravelRequest,
|
selectedTravelRequest,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
sending,
|
||||||
smartEntryContext,
|
smartEntryContext,
|
||||||
smartEntryOpen,
|
smartEntryOpen,
|
||||||
smartEntrySessionId,
|
smartEntrySessionId,
|
||||||
|
|||||||
@@ -109,6 +109,46 @@
|
|||||||
<time>刚刚</time>
|
<time>刚刚</time>
|
||||||
</header>
|
</header>
|
||||||
<p :class="message.role === 'user' ? 'user-question' : 'agent-answer'">{{ message.text }}</p>
|
<p :class="message.role === 'user' ? 'user-question' : 'agent-answer'">{{ message.text }}</p>
|
||||||
|
<div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
|
||||||
|
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
|
||||||
|
<strong>风险标签</strong>
|
||||||
|
<div class="agent-detail-chip-row">
|
||||||
|
<span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.role !== 'user' && message.citations?.length" class="agent-detail-block">
|
||||||
|
<strong>引用依据</strong>
|
||||||
|
<div class="agent-citation-list">
|
||||||
|
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="agent-citation-card">
|
||||||
|
<header>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<small>{{ item.version || item.source_type }}</small>
|
||||||
|
</header>
|
||||||
|
<p>{{ item.excerpt || item.code }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.role !== 'user' && message.suggestedActions?.length" class="agent-detail-block">
|
||||||
|
<strong>建议动作</strong>
|
||||||
|
<div class="agent-detail-chip-row">
|
||||||
|
<span
|
||||||
|
v-for="item in message.suggestedActions"
|
||||||
|
:key="`${message.id}-${item.action_type}-${item.label}`"
|
||||||
|
class="agent-action-chip"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.role !== 'user' && message.draftPayload" class="agent-draft-card">
|
||||||
|
<header>
|
||||||
|
<strong>{{ message.draftPayload.title }}</strong>
|
||||||
|
<span>待人工确认</span>
|
||||||
|
</header>
|
||||||
|
<pre>{{ message.draftPayload.body }}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,17 +170,121 @@
|
|||||||
:value="draft"
|
:value="draft"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
|
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
|
||||||
|
:disabled="sending"
|
||||||
@input="emit('draft', $event.target.value)"
|
@input="emit('draft', $event.target.value)"
|
||||||
@keydown.ctrl.enter.prevent="emit('send')"
|
@keydown.ctrl.enter.prevent="emit('send')"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button class="send-button" type="button" aria-label="发送问题" @click="emit('send')">
|
<button
|
||||||
<i class="mdi mdi-send"></i>
|
class="send-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="发送问题"
|
||||||
|
:disabled="sending || !String(draft || '').trim()"
|
||||||
|
@click="emit('send')"
|
||||||
|
>
|
||||||
|
<i :class="sending ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<aside class="right-column">
|
<aside class="right-column">
|
||||||
|
<article class="panel info-panel semantic-debug-panel">
|
||||||
|
<header>
|
||||||
|
<h3><i class="mdi mdi-shape-outline"></i> 语义解析调试</h3>
|
||||||
|
<button type="button" @click="useDraftAsSemanticInput">带入输入框</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="semantic-debug-body">
|
||||||
|
<label class="semantic-debug-input">
|
||||||
|
<span>自然语言问题</span>
|
||||||
|
<textarea
|
||||||
|
v-model="semanticDraft"
|
||||||
|
rows="4"
|
||||||
|
placeholder="例如:查一下本周报销超标风险"
|
||||||
|
@keydown.ctrl.enter.prevent="parseSemanticQuery"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="semantic-debug-actions">
|
||||||
|
<button
|
||||||
|
v-for="item in semanticExamples"
|
||||||
|
:key="item"
|
||||||
|
class="semantic-chip"
|
||||||
|
type="button"
|
||||||
|
@click="applySemanticExample(item)"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="semantic-debug-toolbar">
|
||||||
|
<button class="semantic-parse-btn" type="button" :disabled="semanticLoading" @click="parseSemanticQuery">
|
||||||
|
<i :class="semanticLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-play-circle-outline'"></i>
|
||||||
|
<span>{{ semanticLoading ? '解析中...' : '开始解析' }}</span>
|
||||||
|
</button>
|
||||||
|
<span class="semantic-inline-meta">
|
||||||
|
<template v-if="semanticResult">run_id:{{ semanticResult.run_id }}</template>
|
||||||
|
<template v-else>支持 Ctrl + Enter</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="semanticError" class="semantic-debug-error">{{ semanticError }}</p>
|
||||||
|
|
||||||
|
<div v-if="semanticResult" class="semantic-result-stack">
|
||||||
|
<div class="semantic-result-grid">
|
||||||
|
<article class="semantic-result-card">
|
||||||
|
<span>场景</span>
|
||||||
|
<strong>{{ semanticResult.scenario }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="semantic-result-card">
|
||||||
|
<span>意图</span>
|
||||||
|
<strong>{{ semanticResult.intent }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="semantic-result-card">
|
||||||
|
<span>权限</span>
|
||||||
|
<strong>{{ semanticResult.permission.level }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="semantic-result-card">
|
||||||
|
<span>置信度</span>
|
||||||
|
<strong>{{ semanticConfidenceLabel }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="semantic-field-list">
|
||||||
|
<section>
|
||||||
|
<h4>实体</h4>
|
||||||
|
<p>{{ semanticEntitiesText }}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>时间</h4>
|
||||||
|
<p>{{ semanticTimeRangeText }}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>指标</h4>
|
||||||
|
<p>{{ semanticMetricsText }}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>约束</h4>
|
||||||
|
<p>{{ semanticConstraintsText }}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>风险</h4>
|
||||||
|
<p>{{ semanticRiskFlagsText }}</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>澄清</h4>
|
||||||
|
<p>{{ semanticClarificationText }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="semantic-json-block">
|
||||||
|
<h4>原始 JSON</h4>
|
||||||
|
<pre>{{ semanticResultJson }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="panel info-panel hot-top-panel">
|
<article class="panel info-panel hot-top-panel">
|
||||||
<header>
|
<header>
|
||||||
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
|
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
|
||||||
|
|||||||
@@ -53,6 +53,51 @@
|
|||||||
</header>
|
</header>
|
||||||
<p>{{ message.text }}</p>
|
<p>{{ message.text }}</p>
|
||||||
|
|
||||||
|
<div v-if="message.role === 'assistant' && message.meta?.length" class="message-meta-row">
|
||||||
|
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.role === 'assistant' && message.riskFlags?.length" class="message-detail-block">
|
||||||
|
<strong>风险标签</strong>
|
||||||
|
<div class="message-detail-chip-row">
|
||||||
|
<span v-for="item in message.riskFlags" :key="item" class="message-risk-chip">{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.role === 'assistant' && message.citations?.length" class="message-detail-block">
|
||||||
|
<strong>引用依据</strong>
|
||||||
|
<div class="message-citation-list">
|
||||||
|
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="message-citation-card">
|
||||||
|
<header>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<small>{{ item.version || item.source_type }}</small>
|
||||||
|
</header>
|
||||||
|
<p>{{ item.excerpt || item.code }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.role === 'assistant' && message.suggestedActions?.length" class="message-detail-block">
|
||||||
|
<strong>建议动作</strong>
|
||||||
|
<div class="message-detail-chip-row">
|
||||||
|
<span
|
||||||
|
v-for="item in message.suggestedActions"
|
||||||
|
:key="`${message.id}-${item.action_type}-${item.label}`"
|
||||||
|
class="message-action-chip"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.role === 'assistant' && message.draftPayload" class="draft-preview">
|
||||||
|
<header>
|
||||||
|
<strong>{{ message.draftPayload.title }}</strong>
|
||||||
|
<span>待人工确认</span>
|
||||||
|
</header>
|
||||||
|
<pre>{{ message.draftPayload.body }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="message.attachments?.length" class="message-files">
|
<div v-if="message.attachments?.length" class="message-files">
|
||||||
<span v-for="file in message.attachments" :key="file" class="file-chip">
|
<span v-for="file in message.attachments" :key="file" class="file-chip">
|
||||||
<i class="mdi mdi-paperclip"></i>
|
<i class="mdi mdi-paperclip"></i>
|
||||||
@@ -78,6 +123,7 @@
|
|||||||
v-model="composerDraft"
|
v-model="composerDraft"
|
||||||
rows="3"
|
rows="3"
|
||||||
:placeholder="composerPlaceholder"
|
:placeholder="composerPlaceholder"
|
||||||
|
:disabled="submitting"
|
||||||
@keydown.ctrl.enter.prevent="submitComposer"
|
@keydown.ctrl.enter.prevent="submitComposer"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -90,14 +136,14 @@
|
|||||||
|
|
||||||
<div class="composer-foot">
|
<div class="composer-foot">
|
||||||
<div class="composer-tools">
|
<div class="composer-tools">
|
||||||
<button type="button" class="tool-btn" aria-label="上传附件" @click="triggerFileUpload">
|
<button type="button" class="tool-btn" :disabled="submitting" aria-label="上传附件" @click="triggerFileUpload">
|
||||||
<i class="mdi mdi-paperclip"></i>
|
<i class="mdi mdi-paperclip"></i>
|
||||||
</button>
|
</button>
|
||||||
<span class="composer-tip">Ctrl + Enter 发送</span>
|
<span class="composer-tip">Ctrl + Enter 发送</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="send-btn" type="submit" :disabled="!canSubmit" aria-label="发送">
|
<button class="send-btn" type="submit" :disabled="!canSubmit" aria-label="发送">
|
||||||
<i class="mdi mdi-send"></i>
|
<i :class="submitting ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,213 +160,132 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="confidence-card">
|
<div class="confidence-card">
|
||||||
<span>意图识别</span>
|
<span>{{ currentInsight.metricLabel }}</span>
|
||||||
<strong>{{ currentInsight.confidence }}%</strong>
|
<strong>{{ currentInsight.metricValue }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="insight-switch" mode="out-in">
|
<Transition name="insight-switch" mode="out-in">
|
||||||
<div :key="currentInsight.intent + currentInsight.title" class="insight-body">
|
<div :key="currentInsight.intent + currentInsight.title" class="insight-body">
|
||||||
<template v-if="currentInsight.intent === 'approval'">
|
<template v-if="currentInsight.intent === 'agent' && currentInsight.agent">
|
||||||
<section class="insight-card primary">
|
<section class="insight-card primary">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>审批状态</h4>
|
<h4>调度结果</h4>
|
||||||
<span class="status-pill warning">{{ currentInsight.status.currentStatus }}</span>
|
<span class="status-pill" :class="currentInsight.agent.statusTone">{{ currentInsight.agent.statusLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span>单号</span>
|
<span>运行 ID</span>
|
||||||
<strong>{{ currentInsight.status.requestId }}</strong>
|
<strong>{{ currentInsight.agent.runId }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span>当前节点</span>
|
<span>执行 Agent</span>
|
||||||
<strong>{{ currentInsight.status.currentNode }}</strong>
|
<strong>{{ currentInsight.agent.selectedAgent }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span>下一处理人</span>
|
<span>场景 / 意图</span>
|
||||||
<strong>{{ currentInsight.status.nextOwner }}</strong>
|
<strong>{{ currentInsight.agent.scenario }} / {{ currentInsight.agent.intent }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span>预计完成</span>
|
<span>权限</span>
|
||||||
<strong>{{ currentInsight.status.eta }}</strong>
|
<strong>{{ currentInsight.agent.permissionLevel }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="insight-card">
|
<section class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>流程节点</h4>
|
<h4>运行明细</h4>
|
||||||
|
</div>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span>路由原因</span>
|
||||||
|
<strong>{{ currentInsight.agent.routeReason }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span>工具调用</span>
|
||||||
|
<strong>{{ currentInsight.agent.toolCount }} / 失败 {{ currentInsight.agent.failedToolCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span>确认要求</span>
|
||||||
|
<strong>{{ currentInsight.agent.requiresConfirmation ? '需要人工确认' : '无需确认' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span>降级状态</span>
|
||||||
|
<strong>{{ currentInsight.agent.degraded ? '已降级' : '正常' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<ol class="timeline-list">
|
|
||||||
<li
|
|
||||||
v-for="step in currentInsight.status.timeline"
|
|
||||||
:key="step.label"
|
|
||||||
:class="step.state"
|
|
||||||
>
|
|
||||||
<span class="timeline-dot"></span>
|
|
||||||
<div>
|
|
||||||
<strong>{{ step.label }}</strong>
|
|
||||||
<p>{{ step.time }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="insight-card">
|
<section v-if="currentInsight.agent.selectedCapabilityCodes?.length" class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>待处理提醒</h4>
|
<h4>命中能力</h4>
|
||||||
|
</div>
|
||||||
|
<div class="capability-chip-row">
|
||||||
|
<span v-for="item in currentInsight.agent.selectedCapabilityCodes" :key="item" class="capability-chip">
|
||||||
|
{{ item }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="currentInsight.agent.fileNames?.length" class="insight-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h4>附件上下文</h4>
|
||||||
</div>
|
</div>
|
||||||
<ul class="bullet-list">
|
<ul class="bullet-list">
|
||||||
<li v-for="item in currentInsight.status.actions" :key="item">{{ item }}</li>
|
<li>本次对话已带入 {{ currentInsight.agent.fileNames.length }} 份附件名称。</li>
|
||||||
|
<li v-for="item in currentInsight.agent.fileNames" :key="item">{{ item }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="currentInsight.intent === 'recognition'">
|
<section v-if="currentInsight.agent.riskFlags?.length" class="insight-card">
|
||||||
<section class="insight-card primary">
|
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>识别结果</h4>
|
<h4>风险标签</h4>
|
||||||
<span class="status-pill success">{{ currentInsight.recognition.state }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-grid">
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>关联单号</span>
|
|
||||||
<strong>{{ currentInsight.recognition.requestId }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>识别附件</span>
|
|
||||||
<strong>{{ currentInsight.recognition.fileCount }} 份</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>建议金额</span>
|
|
||||||
<strong>{{ currentInsight.recognition.amount }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>完整度</span>
|
|
||||||
<strong>{{ currentInsight.recognition.completeness }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="capability-chip-row">
|
||||||
|
<span v-for="item in currentInsight.agent.riskFlags" :key="item" class="risk-chip">{{ item }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="insight-card">
|
<section v-if="currentInsight.agent.citations?.length" class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>票据明细</h4>
|
<h4>引用依据</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="receipt-list">
|
<div class="citation-stack">
|
||||||
<article v-for="item in currentInsight.recognition.receipts" :key="item.name" class="receipt-row">
|
<article v-for="item in currentInsight.agent.citations" :key="item.code" class="citation-card">
|
||||||
|
<header>
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<span>{{ item.version || item.source_type }}</span>
|
||||||
|
</header>
|
||||||
|
<p>{{ item.excerpt || item.code }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="currentInsight.agent.suggestedActions?.length" class="insight-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h4>建议动作</h4>
|
||||||
|
</div>
|
||||||
|
<div class="action-list">
|
||||||
|
<article v-for="item in currentInsight.agent.suggestedActions" :key="item.label" class="action-card">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ item.name }}</strong>
|
<strong>{{ item.label }}</strong>
|
||||||
<p>{{ item.type }}</p>
|
<p>{{ item.description || item.action_type }}</p>
|
||||||
</div>
|
|
||||||
<div class="receipt-side">
|
|
||||||
<strong>{{ item.amount }}</strong>
|
|
||||||
<span>{{ item.confidence }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="insight-card">
|
<section v-if="currentInsight.agent.draftPayload" class="insight-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h4>识别建议</h4>
|
<h4>草稿内容</h4>
|
||||||
</div>
|
|
||||||
<ul class="bullet-list">
|
|
||||||
<li v-for="item in currentInsight.recognition.suggestions" :key="item">{{ item }}</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="currentInsight.intent === 'note'">
|
|
||||||
<section class="insight-card primary">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>补充说明</h4>
|
|
||||||
<span class="status-pill note">{{ currentInsight.note.state }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="note-block">
|
<div class="note-block">
|
||||||
<span>关联单号</span>
|
<span>{{ currentInsight.agent.draftPayload.draft_type }}</span>
|
||||||
<strong>{{ currentInsight.note.requestId }}</strong>
|
<strong>{{ currentInsight.agent.draftPayload.title }}</strong>
|
||||||
<p>{{ currentInsight.note.generatedNote }}</p>
|
<p>{{ currentInsight.agent.draftPayload.body }}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>影响范围</h4>
|
|
||||||
</div>
|
|
||||||
<ul class="bullet-list">
|
|
||||||
<li v-for="item in currentInsight.note.impacts" :key="item">{{ item }}</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>下一步</h4>
|
|
||||||
</div>
|
|
||||||
<div class="metric-grid single">
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>当前处理人</span>
|
|
||||||
<strong>{{ currentInsight.note.owner }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>建议动作</span>
|
|
||||||
<strong>{{ currentInsight.note.nextAction }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="currentInsight.intent === 'draft'">
|
|
||||||
<section class="insight-card primary">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>报销草稿</h4>
|
|
||||||
<span class="status-pill success">{{ currentInsight.draft.state }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-grid">
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>单号</span>
|
|
||||||
<strong>{{ currentInsight.draft.requestId }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>报销类型</span>
|
|
||||||
<strong>{{ currentInsight.draft.type }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>预计金额</span>
|
|
||||||
<strong>{{ currentInsight.draft.amount }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="metric-item">
|
|
||||||
<span>当前进度</span>
|
|
||||||
<strong>{{ currentInsight.draft.progress }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>费用建议</h4>
|
|
||||||
</div>
|
|
||||||
<div class="receipt-list">
|
|
||||||
<article v-for="item in currentInsight.draft.items" :key="item.name" class="receipt-row">
|
|
||||||
<div>
|
|
||||||
<strong>{{ item.name }}</strong>
|
|
||||||
<p>{{ item.desc }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="receipt-side">
|
|
||||||
<strong>{{ item.amount }}</strong>
|
|
||||||
<span>{{ item.tag }}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="insight-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h4>待补信息</h4>
|
|
||||||
</div>
|
|
||||||
<ul class="bullet-list">
|
|
||||||
<li v-for="item in currentInsight.draft.missing" :key="item">{{ item }}</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
|
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ChatView',
|
name: 'ChatView',
|
||||||
props: {
|
props: {
|
||||||
@@ -10,12 +13,18 @@ export default {
|
|||||||
activeCase: { type: Object, default: null },
|
activeCase: { type: Object, default: null },
|
||||||
quickPrompts: { type: Array, required: true },
|
quickPrompts: { type: Array, required: true },
|
||||||
draft: { type: String, default: '' },
|
draft: { type: String, default: '' },
|
||||||
|
sending: { type: Boolean, default: false },
|
||||||
messageList: { type: Object, default: null }
|
messageList: { type: Object, default: null }
|
||||||
},
|
},
|
||||||
emits: ['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'selectCase'],
|
emits: ['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'selectCase'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
const { currentUser } = useSystemState()
|
||||||
const localMessageList = ref(null)
|
const localMessageList = ref(null)
|
||||||
const promptPage = ref(0)
|
const promptPage = ref(0)
|
||||||
|
const semanticDraft = ref('查一下本周报销超标风险')
|
||||||
|
const semanticLoading = ref(false)
|
||||||
|
const semanticError = ref('')
|
||||||
|
const semanticResult = ref(null)
|
||||||
|
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{ title: '北京出差,酒店超标报销怎么处理?', time: '10:32', active: true },
|
{ title: '北京出差,酒店超标报销怎么处理?', time: '10:32', active: true },
|
||||||
@@ -69,12 +78,107 @@ export default {
|
|||||||
{ text: '借款冲销逾期会影响报销吗?', score: '76%' }
|
{ text: '借款冲销逾期会影响报销吗?', score: '76%' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const semanticExamples = [
|
||||||
|
'查一下本周报销超标风险',
|
||||||
|
'客户 A 这个月还有多少应收',
|
||||||
|
'帮我直接付款给供应商B'
|
||||||
|
]
|
||||||
|
|
||||||
|
const semanticConfidenceLabel = computed(() =>
|
||||||
|
semanticResult.value ? `${Math.round((semanticResult.value.confidence || 0) * 100)}%` : '未解析'
|
||||||
|
)
|
||||||
|
const semanticEntitiesText = computed(() => {
|
||||||
|
const items = semanticResult.value?.entities || []
|
||||||
|
return items.length
|
||||||
|
? items.map((item) => `${item.type}:${item.normalized_value}`).join(' / ')
|
||||||
|
: '未识别'
|
||||||
|
})
|
||||||
|
const semanticTimeRangeText = computed(() => {
|
||||||
|
const value = semanticResult.value?.time_range
|
||||||
|
if (!value?.start_date || !value?.end_date) {
|
||||||
|
return '未识别'
|
||||||
|
}
|
||||||
|
return `${value.start_date} ~ ${value.end_date}${value.granularity ? ` · ${value.granularity}` : ''}`
|
||||||
|
})
|
||||||
|
const semanticMetricsText = computed(() => {
|
||||||
|
const items = semanticResult.value?.metrics || []
|
||||||
|
return items.length
|
||||||
|
? items
|
||||||
|
.map((item) => {
|
||||||
|
const suffix = item.top_n ? ` top_${item.top_n}` : ''
|
||||||
|
return `${item.name}${suffix}`
|
||||||
|
})
|
||||||
|
.join(' / ')
|
||||||
|
: '未识别'
|
||||||
|
})
|
||||||
|
const semanticConstraintsText = computed(() => {
|
||||||
|
const items = semanticResult.value?.constraints || []
|
||||||
|
return items.length
|
||||||
|
? items.map((item) => `${item.field}${item.operator}${item.value}`).join(' / ')
|
||||||
|
: '未识别'
|
||||||
|
})
|
||||||
|
const semanticRiskFlagsText = computed(() => {
|
||||||
|
const items = semanticResult.value?.risk_flags || []
|
||||||
|
return items.length ? items.join(' / ') : '未识别'
|
||||||
|
})
|
||||||
|
const semanticClarificationText = computed(() => {
|
||||||
|
if (!semanticResult.value) {
|
||||||
|
return '未解析'
|
||||||
|
}
|
||||||
|
if (!semanticResult.value.clarification_required) {
|
||||||
|
return '无需澄清'
|
||||||
|
}
|
||||||
|
return semanticResult.value.clarification_question || '需要补充更多上下文'
|
||||||
|
})
|
||||||
|
const semanticResultJson = computed(() =>
|
||||||
|
semanticResult.value ? JSON.stringify(semanticResult.value, null, 2) : ''
|
||||||
|
)
|
||||||
|
|
||||||
function rotatePrompts() {
|
function rotatePrompts() {
|
||||||
promptPage.value += 1
|
promptPage.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPrompt(text) {
|
function applyPrompt(text) {
|
||||||
emit('draft', text)
|
emit('draft', text)
|
||||||
|
semanticDraft.value = text
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySemanticExample(text) {
|
||||||
|
semanticDraft.value = text
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDraftAsSemanticInput() {
|
||||||
|
semanticDraft.value = props.draft || semanticDraft.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseSemanticQuery() {
|
||||||
|
const query = String(semanticDraft.value || '').trim()
|
||||||
|
if (!query) {
|
||||||
|
semanticError.value = '请输入要解析的问题。'
|
||||||
|
semanticResult.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
semanticLoading.value = true
|
||||||
|
semanticError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
semanticResult.value = await fetchOntologyParse({
|
||||||
|
query,
|
||||||
|
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
|
||||||
|
context_json: {
|
||||||
|
role_codes: currentUser.value?.roleCodes || [],
|
||||||
|
is_admin: Boolean(currentUser.value?.isAdmin),
|
||||||
|
name: currentUser.value?.name || '',
|
||||||
|
role: currentUser.value?.role || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
semanticResult.value = null
|
||||||
|
semanticError.value = error.message || '语义解析失败,请稍后重试。'
|
||||||
|
} finally {
|
||||||
|
semanticLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -94,7 +198,23 @@ export default {
|
|||||||
hotQuestions,
|
hotQuestions,
|
||||||
similarQuestions,
|
similarQuestions,
|
||||||
rotatePrompts,
|
rotatePrompts,
|
||||||
applyPrompt
|
applyPrompt,
|
||||||
|
semanticDraft,
|
||||||
|
semanticLoading,
|
||||||
|
semanticError,
|
||||||
|
semanticResult,
|
||||||
|
semanticExamples,
|
||||||
|
semanticConfidenceLabel,
|
||||||
|
semanticEntitiesText,
|
||||||
|
semanticTimeRangeText,
|
||||||
|
semanticMetricsText,
|
||||||
|
semanticConstraintsText,
|
||||||
|
semanticRiskFlagsText,
|
||||||
|
semanticClarificationText,
|
||||||
|
semanticResultJson,
|
||||||
|
applySemanticExample,
|
||||||
|
useDraftAsSemanticInput,
|
||||||
|
parseSemanticQuery
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
|
import { runOrchestrator } from '../../services/orchestrator.js'
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'TravelReimbursementCreateView',
|
|
||||||
props: {
|
|
||||||
initialPrompt: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
entrySource: {
|
|
||||||
type: String,
|
|
||||||
default: 'requests'
|
|
||||||
},
|
|
||||||
requestContext: {
|
|
||||||
type: Object,
|
|
||||||
default: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
emits: ['close'] ,
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const DEFAULT_REQUEST = {
|
const DEFAULT_REQUEST = {
|
||||||
id: 'BR240712001',
|
id: 'BR240712001',
|
||||||
reason: '客户方案汇报',
|
reason: '客户方案汇报',
|
||||||
@@ -38,66 +23,49 @@ export default {
|
|||||||
requests: '来自报销列表'
|
requests: '来自报销列表'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCENARIO_LABELS = {
|
||||||
|
expense: '报销',
|
||||||
|
accounts_receivable: '应收',
|
||||||
|
accounts_payable: '应付',
|
||||||
|
knowledge: '知识',
|
||||||
|
unknown: '通用'
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTENT_LABELS = {
|
||||||
|
query: '查询',
|
||||||
|
explain: '解释',
|
||||||
|
compare: '对比',
|
||||||
|
risk_check: '风险检查',
|
||||||
|
draft: '草稿生成',
|
||||||
|
operate: '动作请求'
|
||||||
|
}
|
||||||
|
|
||||||
let messageSeed = 0
|
let messageSeed = 0
|
||||||
|
|
||||||
const fileInputRef = ref(null)
|
function nowTime() {
|
||||||
const messageListRef = ref(null)
|
return new Date().toLocaleTimeString('zh-CN', {
|
||||||
const composerDraft = ref('')
|
hour: '2-digit',
|
||||||
const attachedFiles = ref([])
|
minute: '2-digit',
|
||||||
const messages = ref([])
|
hour12: false
|
||||||
const currentInsight = ref({
|
|
||||||
intent: 'welcome',
|
|
||||||
confidence: 0,
|
|
||||||
title: '',
|
|
||||||
summary: '',
|
|
||||||
welcome: { cards: [] }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
|
|
||||||
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
|
|
||||||
const canSubmit = computed(() => 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 '例如:帮我发起差旅报销、查一下审批节点,或者识别我刚上传的票据。'
|
|
||||||
})
|
function createMessage(role, text, attachments = [], extras = {}) {
|
||||||
const currentIntentLabel = computed(() => {
|
messageSeed += 1
|
||||||
const labels = {
|
return {
|
||||||
welcome: '等待输入',
|
id: `msg-${messageSeed}`,
|
||||||
draft: '报销草稿',
|
role,
|
||||||
approval: '审批查询',
|
text,
|
||||||
recognition: '单据识别',
|
attachments,
|
||||||
note: '补充说明'
|
time: nowTime(),
|
||||||
|
meta: [],
|
||||||
|
citations: [],
|
||||||
|
suggestedActions: [],
|
||||||
|
draftPayload: null,
|
||||||
|
riskFlags: [],
|
||||||
|
...extras
|
||||||
}
|
}
|
||||||
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
|
||||||
})
|
|
||||||
|
|
||||||
const shortcuts = computed(() => [
|
|
||||||
{ label: '查审批节点', icon: 'mdi mdi-timeline-clock-outline', prompt: `帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点了` },
|
|
||||||
{ label: '识别上传单据', icon: 'mdi mdi-file-search-outline', prompt: '我上传了几张票据,帮我识别并给出录入结果' },
|
|
||||||
{ label: '补充报销说明', icon: 'mdi mdi-text-box-edit-outline', prompt: `帮我给 ${linkedRequest.value.id} 补一段费用说明` },
|
|
||||||
{ label: '生成报销草稿', icon: 'mdi mdi-file-document-edit-outline', prompt: '我要发起一笔差旅费申请报销,请帮我先生成草稿' }
|
|
||||||
])
|
|
||||||
|
|
||||||
messages.value = [
|
|
||||||
createMessage(
|
|
||||||
'assistant',
|
|
||||||
buildGreeting(),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
currentInsight.value = buildWelcomeInsight()
|
|
||||||
if (props.initialPrompt?.trim()) {
|
|
||||||
composerDraft.value = props.initialPrompt.trim()
|
|
||||||
submitComposer()
|
|
||||||
} else {
|
|
||||||
nextTick(scrollToBottom)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
function sanitizeRequest(request) {
|
function sanitizeRequest(request) {
|
||||||
if (!request) return { ...DEFAULT_REQUEST }
|
if (!request) return { ...DEFAULT_REQUEST }
|
||||||
@@ -114,51 +82,272 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGreeting() {
|
function resolveStatusLabel(status) {
|
||||||
if (props.entrySource === 'detail') {
|
if (status === 'succeeded') return '已完成'
|
||||||
return `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你可以直接问审批节点、补充说明,或继续上传票据。`
|
if (status === 'blocked') return '已阻断'
|
||||||
}
|
return '失败'
|
||||||
return '这里是统一对话入口。你可以直接发起报销、查询审批节点,或者上传单据让我识别。'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWelcomeInsight() {
|
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 {
|
return {
|
||||||
intent: 'welcome',
|
intent: 'welcome',
|
||||||
confidence: 86,
|
metricLabel: '运行模式',
|
||||||
title: props.entrySource === 'detail' ? `已关联 ${linkedRequest.value.id}` : '先告诉我你要处理什么',
|
metricValue: 'Ready',
|
||||||
summary: props.entrySource === 'detail'
|
title: entrySource === 'detail' ? `已关联 ${linkedRequest.id}` : '已接入真实智能体对话',
|
||||||
? '右侧会跟随你的提问切换成审批状态、识别结果或补充说明界面。'
|
summary:
|
||||||
: '无论是发起报销、查审批还是识别票据,这里都共用一个对话入口。',
|
entrySource === 'detail'
|
||||||
welcome: {
|
? '发送消息后会直接调用 Orchestrator,并返回真实的规则引用、建议动作和草稿结果。'
|
||||||
cards: [
|
: '这里不再使用前端本地意图模拟,所有发送内容都会进入真实 Orchestrator 调度链路。',
|
||||||
{ icon: 'mdi mdi-timeline-clock-outline', title: '审批查询', desc: '识别到审批、节点、状态等意图时,右侧切到流程状态。' },
|
agent: null
|
||||||
{ icon: 'mdi mdi-file-search-outline', title: '票据识别', desc: '上传附件后展示识别结果、建议金额和缺失材料。' },
|
}
|
||||||
{ icon: 'mdi mdi-text-box-check-outline', title: '补充说明', desc: '补充超标、夜间交通、业务招待等说明时,右侧给出结构化备注。' }
|
}
|
||||||
|
|
||||||
|
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} 相关的报销制度依据`
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMessage(role, text, attachments = []) {
|
return [
|
||||||
messageSeed += 1
|
{
|
||||||
return {
|
label: '查本周报销金额',
|
||||||
id: `msg-${messageSeed}`,
|
icon: 'mdi mdi-cash-multiple',
|
||||||
role,
|
prompt: '查一下本周报销金额'
|
||||||
text,
|
},
|
||||||
attachments,
|
{
|
||||||
time: nowTime()
|
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待付款多少'
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
|
|
||||||
function nowTime() {
|
|
||||||
return new Date().toLocaleTimeString('zh-CN', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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() {
|
function triggerFileUpload() {
|
||||||
|
if (submitting.value) return
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,18 +360,43 @@ export default {
|
|||||||
submitComposer()
|
submitComposer()
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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
|
if (!canSubmit.value) return
|
||||||
|
|
||||||
const rawText = composerDraft.value.trim()
|
const rawText = composerDraft.value.trim()
|
||||||
const fileNames = attachedFiles.value.map((file) => file.name)
|
const files = Array.from(attachedFiles.value)
|
||||||
const userText = rawText || `我上传了 ${fileNames.length} 份单据,请帮我识别并录入。`
|
const fileNames = files.map((file) => file.name)
|
||||||
|
const userText =
|
||||||
|
rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`
|
||||||
|
const backendMessage = buildBackendMessage(rawText, fileNames)
|
||||||
|
|
||||||
messages.value.push(createMessage('user', userText, fileNames))
|
messages.value.push(createMessage('user', userText, fileNames))
|
||||||
|
|
||||||
const insight = analyzeIntent(userText, fileNames)
|
const pendingMessage = createMessage('assistant', 'Orchestrator 正在处理中...', [], {
|
||||||
currentInsight.value = insight
|
meta: ['运行中']
|
||||||
messages.value.push(createMessage('assistant', insight.reply))
|
})
|
||||||
|
messages.value.push(pendingMessage)
|
||||||
|
|
||||||
composerDraft.value = ''
|
composerDraft.value = ''
|
||||||
attachedFiles.value = []
|
attachedFiles.value = []
|
||||||
@@ -190,232 +404,57 @@ export default {
|
|||||||
fileInputRef.value.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)
|
nextTick(scrollToBottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
if (!messageListRef.value) return
|
|
||||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
function analyzeIntent(text, files) {
|
|
||||||
if (isRecognitionIntent(text, files)) return buildRecognitionInsight(text, files)
|
|
||||||
if (isApprovalIntent(text)) return buildApprovalInsight(text)
|
|
||||||
if (isNoteIntent(text)) return buildNoteInsight(text)
|
|
||||||
return buildDraftInsight(text, files)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecognitionIntent(text, files) {
|
|
||||||
return files.length > 0 || /(上传|附件|票据|发票|单据|识别|ocr)/i.test(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isApprovalIntent(text) {
|
|
||||||
return /(审批|节点|状态|进度|流程|卡在哪|到哪了|通过了吗|驳回)/.test(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNoteIntent(text) {
|
|
||||||
return /(说明|备注|补充|原因|超标|夜间|特殊情况|备注一下)/.test(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildApprovalInsight(text) {
|
|
||||||
const requestId = extractRequestId(text) || linkedRequest.value.id
|
|
||||||
const timeline = [
|
|
||||||
{ label: '提交申请', time: `${linkedRequest.value.applyTime} 09:18`, state: 'done' },
|
|
||||||
{ label: '票据识别', time: `${linkedRequest.value.applyTime} 09:22`, state: 'done' },
|
|
||||||
{ label: '直属主管审批', time: '今天 10:46', state: 'done' },
|
|
||||||
{ label: linkedRequest.value.node, time: '进行中', state: 'current' },
|
|
||||||
{ label: '归档入账', time: '待处理', state: 'pending' }
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
intent: 'approval',
|
|
||||||
confidence: 95,
|
|
||||||
title: `${requestId} 的审批状态`,
|
|
||||||
summary: `当前在 ${linkedRequest.value.node},右侧已经切到流程状态界面。`,
|
|
||||||
reply: `我识别到你是在查询审批节点。${requestId} 当前处于 ${linkedRequest.value.node},下一步预计由财务在今天 17:30 前处理。`,
|
|
||||||
status: {
|
|
||||||
requestId,
|
|
||||||
currentStatus: linkedRequest.value.approval,
|
|
||||||
currentNode: linkedRequest.value.node,
|
|
||||||
nextOwner: '财务共享中心 · 王敏',
|
|
||||||
eta: '今天 17:30 前',
|
|
||||||
timeline,
|
|
||||||
actions: [
|
|
||||||
'若 17:30 后仍未推进,可提醒财务共享中心处理。',
|
|
||||||
'当前不建议重复提交,避免流程串单。',
|
|
||||||
'如果要补充说明,直接在当前对话里继续输入即可。'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRecognitionInsight(text, files) {
|
|
||||||
const requestId = extractRequestId(text) || linkedRequest.value.id
|
|
||||||
const receipts = buildReceiptItems(text, files)
|
|
||||||
const total = receipts.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
|
|
||||||
const amount = formatCurrency(total || guessAmount(text) || 3680)
|
|
||||||
const completeness = files.length >= 2 ? '资料较完整' : '仍需补件'
|
|
||||||
|
|
||||||
return {
|
|
||||||
intent: 'recognition',
|
|
||||||
confidence: files.length ? 97 : 90,
|
|
||||||
title: '已切换到单据识别视图',
|
|
||||||
summary: `识别到 ${receipts.length} 条候选费用,建议关联到 ${requestId}。`,
|
|
||||||
reply: `我识别到你是在上传或识别单据。右侧已经展示识别结果、建议金额和缺失材料。`,
|
|
||||||
recognition: {
|
|
||||||
state: files.length ? '识别完成' : '待补附件',
|
|
||||||
requestId,
|
|
||||||
fileCount: Math.max(files.length, 1),
|
|
||||||
amount,
|
|
||||||
completeness,
|
|
||||||
receipts,
|
|
||||||
suggestions: [
|
|
||||||
files.length ? '可直接生成费用明细草稿。' : '建议补传票据原件,识别结果会更稳定。',
|
|
||||||
'金额和费用分类已经给出,确认后即可写入报销单。',
|
|
||||||
'如果有多张单据属于同一行程,可以继续上传,右侧会合并结果。'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNoteInsight(text) {
|
|
||||||
const requestId = extractRequestId(text) || linkedRequest.value.id
|
|
||||||
const noteType = /超标|夜间/.test(text) ? '特殊场景说明' : '补充报销说明'
|
|
||||||
const generatedNote = /超标|夜间/.test(text)
|
|
||||||
? '因客户会议结束较晚,产生夜间交通费用,已保留行程截图与打车凭证,申请按实际发生金额报销。'
|
|
||||||
: '本次费用与客户现场沟通及方案汇报直接相关,单据与行程已对应关联,请按当前草稿继续流转。'
|
|
||||||
|
|
||||||
return {
|
|
||||||
intent: 'note',
|
|
||||||
confidence: 93,
|
|
||||||
title: `${requestId} 的补充说明`,
|
|
||||||
summary: `识别到你是在补充备注,右侧切到说明整理界面。`,
|
|
||||||
reply: `我识别到你是在补充说明。右侧已经生成结构化备注,可直接作为对应单号的附加说明。`,
|
|
||||||
note: {
|
|
||||||
requestId,
|
|
||||||
state: noteType,
|
|
||||||
generatedNote,
|
|
||||||
impacts: [
|
|
||||||
'会同步显示给当前审批节点处理人。',
|
|
||||||
'若涉及超标或夜间交通,审批意见会优先查看这段说明。',
|
|
||||||
'继续补充金额、参与人或业务背景时,我会自动更新说明版本。'
|
|
||||||
],
|
|
||||||
owner: linkedRequest.value.node,
|
|
||||||
nextAction: '继续补充或提交当前说明'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraftInsight(text, files) {
|
|
||||||
const requestId = linkedRequest.value.id
|
|
||||||
const items = buildDraftItems(text, files)
|
|
||||||
const total = items.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
intent: 'draft',
|
|
||||||
confidence: 91,
|
|
||||||
title: '已切换到报销草稿视图',
|
|
||||||
summary: '识别到你是在发起报销或继续填写草稿,右侧展示当前建议明细。',
|
|
||||||
reply: '我识别到你是在发起或继续整理报销。右侧已经切到草稿视图,展示建议费用明细和待补信息。',
|
|
||||||
draft: {
|
|
||||||
state: files.length ? '可继续完善' : '草稿已生成',
|
|
||||||
requestId,
|
|
||||||
type: inferDraftType(text),
|
|
||||||
amount: formatCurrency(total || guessAmount(text) || 3280),
|
|
||||||
progress: files.length ? '已录入基础信息' : '待补票据',
|
|
||||||
items,
|
|
||||||
missing: [
|
|
||||||
'补充至少一份原始票据或行程截图。',
|
|
||||||
'确认出差事由、城市和发生日期是否完整。',
|
|
||||||
'如有业务招待或特殊交通,请补充关联说明。'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractRequestId(text) {
|
|
||||||
return text.match(/BR\d{6,}/i)?.[0] ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferDraftType(text) {
|
|
||||||
if (/招待|客户|用餐/.test(text)) return '业务招待报销'
|
|
||||||
if (/交通|打车|高铁|机票/.test(text)) return '交通费用报销'
|
|
||||||
return '差旅费申请报销'
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraftItems(text, files) {
|
|
||||||
const items = []
|
|
||||||
|
|
||||||
if (/高铁|火车|车票/.test(text)) {
|
|
||||||
items.push({ name: '高铁 / 火车票', desc: '建议录入为城际交通', amount: '¥236.00', tag: '交通' })
|
|
||||||
}
|
|
||||||
if (/机票|航班/.test(text)) {
|
|
||||||
items.push({ name: '机票', desc: '建议录入为航空出行', amount: '¥1,280.00', tag: '交通' })
|
|
||||||
}
|
|
||||||
if (/酒店|住宿/.test(text)) {
|
|
||||||
items.push({ name: '酒店住宿', desc: '建议录入为住宿费用', amount: '¥780.00', tag: '住宿' })
|
|
||||||
}
|
|
||||||
if (/打车|出租车|网约车/.test(text)) {
|
|
||||||
items.push({ name: '市内交通', desc: '建议合并同日打车订单', amount: '¥126.00', tag: '交通' })
|
|
||||||
}
|
|
||||||
if (/餐|招待|客户/.test(text)) {
|
|
||||||
items.push({ name: '业务招待', desc: '建议补充参与人和业务目的', amount: '¥860.00', tag: '招待' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
items.push({
|
|
||||||
name: '差旅综合费用',
|
|
||||||
desc: files.length ? '已根据附件生成候选明细' : '根据描述先生成一版草稿',
|
|
||||||
amount: files.length ? '¥3,280.00' : '¥2,680.00',
|
|
||||||
tag: '草稿'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReceiptItems(text, files) {
|
|
||||||
if (files.length) {
|
|
||||||
return files.map((file, index) => {
|
|
||||||
const type = inferFileType(file, text, index)
|
|
||||||
const baseAmount = guessAmount(file) || guessAmount(text) || (index + 1) * 180 + 120
|
|
||||||
return {
|
|
||||||
name: file,
|
|
||||||
type,
|
|
||||||
amount: formatCurrency(baseAmount),
|
|
||||||
confidence: `${92 - index}%`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildDraftItems(text, files).map((item, index) => ({
|
|
||||||
name: item.name,
|
|
||||||
type: item.tag,
|
|
||||||
amount: item.amount,
|
|
||||||
confidence: `${94 - index}%`
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferFileType(fileName, text, index) {
|
|
||||||
const name = `${fileName} ${text}`
|
|
||||||
if (/酒店|住宿/.test(name)) return '住宿单据'
|
|
||||||
if (/机票|航班/.test(name)) return '航空出行'
|
|
||||||
if (/高铁|火车|车票/.test(name)) return '城际交通'
|
|
||||||
if (/打车|出租车|网约车/.test(name)) return '市内交通'
|
|
||||||
return index === 0 ? '费用主票据' : '补充附件'
|
|
||||||
}
|
|
||||||
|
|
||||||
function guessAmount(text) {
|
|
||||||
const match = String(text).match(/(\d+(?:\.\d{1,2})?)/)
|
|
||||||
return match ? Number.parseFloat(match[1]) : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCurrency(value) {
|
|
||||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCurrency(value) {
|
|
||||||
return `¥${Number(value).toFixed(2)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -424,6 +463,7 @@ export default {
|
|||||||
messageListRef,
|
messageListRef,
|
||||||
composerDraft,
|
composerDraft,
|
||||||
attachedFiles,
|
attachedFiles,
|
||||||
|
submitting,
|
||||||
messages,
|
messages,
|
||||||
currentInsight,
|
currentInsight,
|
||||||
linkedRequest,
|
linkedRequest,
|
||||||
@@ -440,4 +480,3 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user