feat(web): 工作台 AI 模式报销预审与文档查询模型拆分
- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出 - PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示 - 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试 - 新增 AI 文档卡片背景资源
This commit is contained in:
@@ -303,10 +303,121 @@
|
||||
<div
|
||||
v-if="message.content"
|
||||
class="workbench-ai-answer-markdown"
|
||||
v-html="renderInlineMarkdown(message.content)"
|
||||
@click.capture="handleAiAnswerMarkdownClick($event)"
|
||||
v-html="renderInlineConversationHtml(message.content)"
|
||||
></div>
|
||||
|
||||
<div v-else-if="message.pending && !hasInlineThinking(message)" class="workbench-ai-pending-line">
|
||||
<Transition name="structured-card-reveal" appear>
|
||||
<div
|
||||
v-if="message.applicationPreview"
|
||||
class="workbench-ai-application-preview application-preview-shell"
|
||||
aria-label="申请信息核对结果"
|
||||
>
|
||||
<div
|
||||
class="application-preview-table"
|
||||
role="table"
|
||||
aria-label="申请信息核对表"
|
||||
>
|
||||
<div class="application-preview-row head" role="row">
|
||||
<span role="columnheader">字段</span>
|
||||
<span role="columnheader">内容</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in resolveInlineApplicationPreviewRows(message)"
|
||||
:key="`${message.id}-${row.key}`"
|
||||
class="application-preview-row"
|
||||
:class="{
|
||||
missing: row.missing,
|
||||
editable: row.editable,
|
||||
highlight: row.highlight
|
||||
}"
|
||||
role="row"
|
||||
:tabindex="row.editable ? 0 : -1"
|
||||
:aria-label="row.editable ? `编辑${row.label}` : row.label"
|
||||
@click.stop="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.enter.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
@keydown.space.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
||||
<span class="application-preview-value" role="cell">
|
||||
<input
|
||||
v-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'text'"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input"
|
||||
type="text"
|
||||
autofocus
|
||||
@click.stop
|
||||
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="commitInlineApplicationPreviewEditor(message)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input application-preview-select"
|
||||
autofocus
|
||||
@click.stop
|
||||
@change="commitInlineApplicationPreviewEditor(message)"
|
||||
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="commitInlineApplicationPreviewEditor(message)"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
<option
|
||||
v-for="option in resolveApplicationPreviewEditorOptions(row.key)"
|
||||
:key="`${message.id}-${row.key}-${option}`"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<template v-else>
|
||||
<span class="application-preview-text">{{ row.value }}</span>
|
||||
<button
|
||||
v-if="row.editable"
|
||||
type="button"
|
||||
class="application-preview-edit-btn"
|
||||
title="修改内容"
|
||||
aria-label="修改内容"
|
||||
@click.stop="openApplicationPreviewEditor(message, row.key, row.value)"
|
||||
>
|
||||
<i class="mdi mdi-pencil-outline"></i>
|
||||
</button>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="resolveInlineApplicationPreviewMissingFields(message).length"
|
||||
class="application-preview-footer application-preview-footer-missing"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="application-preview-missing-prefix">当前还需要补充:</span>
|
||||
<span class="application-preview-missing-list">
|
||||
<template
|
||||
v-for="(field, index) in resolveInlineApplicationPreviewMissingFields(message)"
|
||||
:key="`${message.id}-missing-${field}`"
|
||||
>
|
||||
<span class="application-preview-missing-chip">{{ field }}</span>
|
||||
<span
|
||||
v-if="index < resolveInlineApplicationPreviewMissingFields(message).length - 1"
|
||||
class="application-preview-missing-separator"
|
||||
>、</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="application-preview-missing-suffix">。点击表格字段补齐后,费用测算会自动刷新。</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="buildInlineApplicationPreviewFooterText(message)"
|
||||
class="application-preview-footer workbench-ai-answer-markdown"
|
||||
v-html="renderInlineConversationHtml(buildInlineApplicationPreviewFooterText(message))"
|
||||
></div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="!message.content && !message.applicationPreview && message.pending && !hasInlineThinking(message)"
|
||||
class="workbench-ai-pending-line"
|
||||
>
|
||||
小财管家正在识别任务、拆解流程并准备下一步建议...
|
||||
</div>
|
||||
|
||||
@@ -523,7 +634,7 @@ import {
|
||||
loadAiWorkbenchConversationHistory,
|
||||
saveAiWorkbenchConversation
|
||||
} from '../../utils/aiWorkbenchConversationStore.js'
|
||||
import { renderMarkdown } from '../../utils/markdown.js'
|
||||
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
@@ -549,25 +660,39 @@ import {
|
||||
isAiExpenseDraftComplete
|
||||
} from '../../utils/aiExpenseDraftModel.js'
|
||||
import {
|
||||
applyAiApplicationAnswer,
|
||||
buildAiApplicationStepPrompt,
|
||||
buildAiApplicationSummary,
|
||||
createAiApplicationDraft,
|
||||
isAiApplicationDraftComplete
|
||||
} from '../../utils/aiApplicationDraftModel.js'
|
||||
buildApplicationPreviewFooterMessage,
|
||||
buildApplicationPreviewRows,
|
||||
buildApplicationTemplatePreview,
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
normalizeApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
|
||||
import {
|
||||
buildAiDocumentQueryConditionSummary,
|
||||
buildAiDocumentQueryMessage,
|
||||
filterAiDocumentQueryRecords,
|
||||
resolveAiDocumentQueryIntent
|
||||
} from '../../utils/aiDocumentQueryModel.js'
|
||||
import {
|
||||
buildRequiredApplicationActions,
|
||||
buildRequiredApplicationMissingText,
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates
|
||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
calculateTravelReimbursement,
|
||||
extractExpenseClaimItems,
|
||||
fetchApprovalExpenseClaims,
|
||||
fetchExpenseClaims
|
||||
} from '../../services/reimbursements.js'
|
||||
|
||||
const props = defineProps({
|
||||
sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['conversation-change', 'conversation-history-change'])
|
||||
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document'])
|
||||
const AI_DOCUMENT_QUERY_STEP_DELAY_MS = 320
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const assistantDraft = ref('')
|
||||
@@ -584,7 +709,6 @@ const activeConversationTitle = ref('')
|
||||
const sending = ref(false)
|
||||
const stewardState = ref(null)
|
||||
const aiExpenseDraft = ref(null)
|
||||
const aiApplicationDraft = ref(null)
|
||||
const thinkingExpandedMessageIds = ref(new Set())
|
||||
const thinkingCollapsedMessageIds = ref(new Set())
|
||||
const deleteDialogOpen = ref(false)
|
||||
@@ -594,6 +718,24 @@ const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
|
||||
const INLINE_ANSWER_STREAM_DELAY_MS = 24
|
||||
const INLINE_AUTO_SCROLL_THRESHOLD = 96
|
||||
const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260
|
||||
const AI_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||
|
||||
const {
|
||||
applicationPreviewEditor,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
refreshApplicationPreviewEstimate,
|
||||
isApplicationPreviewEditing,
|
||||
openApplicationPreviewEditor,
|
||||
commitApplicationPreviewEditor,
|
||||
cancelApplicationPreviewEditor,
|
||||
handleApplicationPreviewEditorKeydown
|
||||
} = useApplicationPreviewEditor({
|
||||
persistSessionState: () => persistCurrentConversation(),
|
||||
toast,
|
||||
calculateTravelReimbursement,
|
||||
currentUser
|
||||
})
|
||||
|
||||
const {
|
||||
workbenchDatePickerOpen,
|
||||
@@ -753,6 +895,8 @@ function createInlineMessage(role, content, options = {}) {
|
||||
feedback: String(options.feedback || ''),
|
||||
stewardPlan: options.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
|
||||
applicationPreview: options.applicationPreview || null,
|
||||
text: options.text || normalizedContent,
|
||||
createdAt: options.createdAt || Date.now()
|
||||
}
|
||||
}
|
||||
@@ -807,7 +951,9 @@ function normalizeRuntimeMessage(message = {}) {
|
||||
pending: false,
|
||||
feedback: message.feedback || '',
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : []
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
text: message.text || message.content || ''
|
||||
})
|
||||
}
|
||||
|
||||
@@ -816,9 +962,11 @@ function serializeRuntimeMessage(message = {}) {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
text: message.text || message.content || '',
|
||||
feedback: message.feedback || '',
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : []
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
applicationPreview: message.applicationPreview || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,8 +1035,59 @@ function activateInlineConversation(options = {}) {
|
||||
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(content) {
|
||||
return renderMarkdown(content)
|
||||
function renderInlineConversationHtml(content) {
|
||||
return renderAiConversationHtml(content)
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewRows(message) {
|
||||
return buildApplicationPreviewRows(message?.applicationPreview || {})
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewMissingFields(message) {
|
||||
return normalizeApplicationPreview(message?.applicationPreview || {}).missingFields || []
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
|
||||
const control = resolveApplicationPreviewEditorControl(fieldKey)
|
||||
return control === 'date' ? 'text' : control
|
||||
}
|
||||
|
||||
function syncInlineApplicationPreviewMessageContent(message) {
|
||||
if (!message?.applicationPreview) {
|
||||
return
|
||||
}
|
||||
const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview)
|
||||
message.content = nextContent
|
||||
message.text = nextContent
|
||||
}
|
||||
|
||||
async function commitInlineApplicationPreviewEditor(message) {
|
||||
const committed = await commitApplicationPreviewEditor(message)
|
||||
syncInlineApplicationPreviewMessageContent(message)
|
||||
persistCurrentConversation()
|
||||
return committed
|
||||
}
|
||||
|
||||
function handleInlineApplicationPreviewEditorKeydown(event, message) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void commitInlineApplicationPreviewEditor(message)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelApplicationPreviewEditor()
|
||||
return
|
||||
}
|
||||
handleApplicationPreviewEditorKeydown(event, message)
|
||||
}
|
||||
|
||||
function buildInlineApplicationPreviewFooterText(message) {
|
||||
const normalized = normalizeApplicationPreview(message?.applicationPreview || {})
|
||||
if (normalized.validationIssues?.length || normalized.missingFields?.length) {
|
||||
return buildApplicationPreviewFooterMessage(normalized)
|
||||
}
|
||||
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以回复“保存草稿”或“提交申请”。'
|
||||
}
|
||||
|
||||
function resolveInlineThinkingEvents(message) {
|
||||
@@ -1033,18 +1232,17 @@ function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
|
||||
return baseText
|
||||
}
|
||||
|
||||
function continueAiRequiredApplicationGateFromPlan(normalizedPlan) {
|
||||
function continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt = '') {
|
||||
const flow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
|
||||
if (!flow) {
|
||||
return false
|
||||
}
|
||||
if (flow.flowId === 'travel_application') {
|
||||
aiExpenseDraft.value = null
|
||||
startAiApplicationDraft('travel', '差旅费')
|
||||
void startAiApplicationPreview('travel', '差旅费', prompt)
|
||||
return true
|
||||
}
|
||||
if (flow.flowId === 'travel_reimbursement') {
|
||||
aiApplicationDraft.value = null
|
||||
startAiExpenseDraft('travel', '差旅费', true)
|
||||
return true
|
||||
}
|
||||
@@ -1125,6 +1323,191 @@ async function fetchInlineStewardPlan(messageId, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAiDocumentDetailHref(href = '') {
|
||||
const value = String(href || '').trim()
|
||||
if (!value.startsWith(AI_DOCUMENT_DETAIL_HREF_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
const encodedReference = value.slice(AI_DOCUMENT_DETAIL_HREF_PREFIX.length)
|
||||
if (!encodedReference) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const reference = decodeURIComponent(encodedReference).trim()
|
||||
return reference ? { reference } : null
|
||||
} catch {
|
||||
return { reference: encodedReference }
|
||||
}
|
||||
}
|
||||
|
||||
function buildAiDocumentDetailRequest(detailReference = {}) {
|
||||
const reference = String(detailReference.reference || '').trim()
|
||||
const isApplication = /^APP?-/i.test(reference)
|
||||
return {
|
||||
id: reference,
|
||||
claimId: reference,
|
||||
claimNo: reference,
|
||||
documentNo: reference,
|
||||
documentType: isApplication ? 'application' : 'reimbursement',
|
||||
documentTypeCode: isApplication ? 'application' : 'reimbursement',
|
||||
source: 'workbench',
|
||||
returnTo: 'workbench'
|
||||
}
|
||||
}
|
||||
|
||||
function handleAiAnswerMarkdownClick(event) {
|
||||
const target = event?.target
|
||||
const link = target?.closest?.('a[href^="#ai-open-document-detail:"]')
|
||||
if (!link) {
|
||||
return
|
||||
}
|
||||
const detailReference = parseAiDocumentDetailHref(link.getAttribute('href'))
|
||||
if (!detailReference) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('open-document', buildAiDocumentDetailRequest(detailReference))
|
||||
}
|
||||
|
||||
function waitForAiDocumentQueryStep() {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, AI_DOCUMENT_QUERY_STEP_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
async function updateAiDocumentQueryThinking(pendingMessage, thinkingEvents, streamStatus = 'streaming') {
|
||||
const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
|
||||
message.stewardPlan = {
|
||||
...(message.stewardPlan || {}),
|
||||
streamStatus,
|
||||
thinkingEvents
|
||||
}
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function completeAiDocumentQueryEvent(events, eventId, content = '') {
|
||||
return events.map((event) => (
|
||||
event.eventId === eventId
|
||||
? {
|
||||
...event,
|
||||
content: content || event.content,
|
||||
status: 'completed'
|
||||
}
|
||||
: event
|
||||
))
|
||||
}
|
||||
|
||||
function failAiDocumentQueryEvents(events) {
|
||||
return events.map((event) => ({
|
||||
...event,
|
||||
status: event.status === 'completed' ? 'completed' : 'failed'
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
|
||||
const intent = resolveAiDocumentQueryIntent(prompt)
|
||||
if (!intent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
||||
let thinkingEvents = [
|
||||
{
|
||||
eventId: 'document-query-parse',
|
||||
title: '解析自然语言筛选条件',
|
||||
content: `正在从你的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
eventId: 'document-query-fetch',
|
||||
title: '查询业务单据接口',
|
||||
content: intent.source === 'approval' ? '等待调用待我审核单据接口。' : '等待调用我名下单据接口。',
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
eventId: 'document-query-filter',
|
||||
title: '组合筛选单据',
|
||||
content: '等待接口返回后,再按已识别条件做二次筛选。',
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
await waitForAiDocumentQueryStep()
|
||||
|
||||
thinkingEvents = completeAiDocumentQueryEvent(thinkingEvents, 'document-query-parse')
|
||||
thinkingEvents = thinkingEvents.map((event) => (
|
||||
event.eventId === 'document-query-fetch'
|
||||
? {
|
||||
...event,
|
||||
content: intent.source === 'approval'
|
||||
? '正在查询待我审核的单据,接口范围为待办/待审单据列表。'
|
||||
: '正在查询我名下的单据,接口范围为当前用户可见单据列表。',
|
||||
status: 'running'
|
||||
}
|
||||
: event
|
||||
))
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
|
||||
try {
|
||||
const payload = intent.source === 'approval'
|
||||
? await fetchApprovalExpenseClaims({ page: 1, pageSize: 100 })
|
||||
: await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
const rawCount = extractExpenseClaimItems(payload).length
|
||||
const filteredRecords = filterAiDocumentQueryRecords(payload, intent)
|
||||
thinkingEvents = completeAiDocumentQueryEvent(
|
||||
thinkingEvents,
|
||||
'document-query-fetch',
|
||||
`接口返回 ${rawCount} 张候选单据,开始按自然语言条件筛选。`
|
||||
)
|
||||
thinkingEvents = thinkingEvents.map((event) => (
|
||||
event.eventId === 'document-query-filter'
|
||||
? {
|
||||
...event,
|
||||
content: `按“${conditionSummary}”组合筛选,当前命中 ${filteredRecords.length} 张。`,
|
||||
status: 'running'
|
||||
}
|
||||
: event
|
||||
))
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
await waitForAiDocumentQueryStep()
|
||||
|
||||
const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
|
||||
thinkingEvents = completeAiDocumentQueryEvent(
|
||||
thinkingEvents,
|
||||
'document-query-filter',
|
||||
`筛选完成,命中 ${filteredRecords.length} 张单据,已生成卡片结果。`
|
||||
)
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents
|
||||
},
|
||||
suggestedActions: []
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
const finalMessageText = error?.message || '查询单据时出现异常,请稍后再试。'
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', finalMessageText, {
|
||||
id: pendingMessage.id,
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: failAiDocumentQueryEvents(thinkingEvents)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
persistCurrentConversation()
|
||||
return true
|
||||
}
|
||||
|
||||
async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
|
||||
let shouldAutoScrollOnFinish = true
|
||||
const pendingMessage = createInlineMessage('assistant', '', {
|
||||
@@ -1145,6 +1528,11 @@ async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
return
|
||||
}
|
||||
|
||||
const planRequest = buildStewardPlanRequest({
|
||||
rawText: prompt,
|
||||
files,
|
||||
@@ -1201,7 +1589,7 @@ async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
|
||||
suggestedActions: requiredApplicationContinuationFlow ? [] : buildStewardSuggestedActions(plan)
|
||||
})
|
||||
)
|
||||
if (continueAiRequiredApplicationGateFromPlan(normalizedPlan)) {
|
||||
if (continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt)) {
|
||||
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
||||
}
|
||||
persistCurrentConversation()
|
||||
@@ -1243,11 +1631,6 @@ function startInlineConversation(prompt, entry = {}, files = []) {
|
||||
return
|
||||
}
|
||||
|
||||
if (aiApplicationDraft.value && !isAiApplicationDraftComplete(aiApplicationDraft.value)) {
|
||||
advanceAiApplicationDraft(cleanPrompt, files)
|
||||
return
|
||||
}
|
||||
|
||||
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
|
||||
conversationId.value = ''
|
||||
conversationMessages.value = []
|
||||
@@ -1362,7 +1745,11 @@ function handleInlineSuggestedAction(action = {}) {
|
||||
aiExpenseDraft.value = null
|
||||
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
|
||||
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
|
||||
startAiApplicationDraft(expenseType, expenseTypeLabel)
|
||||
void startAiApplicationPreview(
|
||||
expenseType,
|
||||
expenseTypeLabel,
|
||||
actionPayload.carry_text || resolveLatestInlineUserPrompt()
|
||||
)
|
||||
return
|
||||
}
|
||||
if (actionType === 'select_expense_type') {
|
||||
@@ -1382,7 +1769,11 @@ function handleInlineSuggestedAction(action = {}) {
|
||||
aiExpenseDraft.value = null
|
||||
const expenseType = String(action?.payload?.expense_type || '').trim()
|
||||
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
|
||||
startAiApplicationDraft(expenseType, expenseTypeLabel)
|
||||
void startAiApplicationPreview(
|
||||
expenseType,
|
||||
expenseTypeLabel,
|
||||
action?.payload?.carry_text || resolveLatestInlineUserPrompt()
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1423,6 +1814,46 @@ function pushInlineUserMessage(text) {
|
||||
conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
|
||||
}
|
||||
|
||||
function resolveLatestInlineUserPrompt() {
|
||||
const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
|
||||
return String(latestUserMessage?.content || '').trim()
|
||||
}
|
||||
|
||||
function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {
|
||||
const label = String(expenseTypeLabel || '').trim()
|
||||
if (!label) {
|
||||
return fallback
|
||||
}
|
||||
if (label.endsWith('费用申请') || label.endsWith('申请')) {
|
||||
return label
|
||||
}
|
||||
if (label.endsWith('费用')) {
|
||||
return `${label}申请`
|
||||
}
|
||||
if (label.endsWith('费')) {
|
||||
return `${label.slice(0, -1)}费用申请`
|
||||
}
|
||||
return `${label}申请`
|
||||
}
|
||||
|
||||
function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '') {
|
||||
const rawText = String(sourceText || '').trim()
|
||||
const preview = rawText
|
||||
? buildLocalApplicationPreview(rawText, currentUser.value || {})
|
||||
: buildApplicationTemplatePreview(currentUser.value || {})
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
return normalizeApplicationPreview({
|
||||
...normalized,
|
||||
fields: {
|
||||
...(normalized.fields || {}),
|
||||
applicationType: normalizeInlineApplicationTypeLabel(
|
||||
expenseTypeLabel,
|
||||
normalized.fields?.applicationType || '费用申请'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 选定报销类型后,在当前对话页内启动逐项收集流程;
|
||||
// 差旅/招待需先查申请单,其余类型直接进入字段填写。
|
||||
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
|
||||
@@ -1537,32 +1968,28 @@ function linkAiExpenseApplication(application = {}) {
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
// 进入申请草稿:在当前 AI 对话页内逐项收集出差申请要点,
|
||||
// 不跳工作台、不调用旧 applyGuided 流程。
|
||||
function startAiApplicationDraft(expenseType, expenseTypeLabel) {
|
||||
pushInlineUserMessage('在当前对话里先发起申请')
|
||||
const draft = createAiApplicationDraft(expenseType, expenseTypeLabel)
|
||||
aiApplicationDraft.value = draft
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiApplicationStepPrompt(draft)))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function advanceAiApplicationDraft(answer, files = []) {
|
||||
const fileNames = Array.from(files || [])
|
||||
pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : ''))
|
||||
assistantDraft.value = ''
|
||||
clearAiModeFiles()
|
||||
|
||||
const next = applyAiApplicationAnswer(aiApplicationDraft.value, answer, fileNames)
|
||||
aiApplicationDraft.value = next
|
||||
|
||||
if (isAiApplicationDraftComplete(next)) {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiApplicationSummary(next)))
|
||||
aiApplicationDraft.value = null
|
||||
} else {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiApplicationStepPrompt(next)))
|
||||
// 进入申请核对表:复用原有申请预览模型,一次性展示可编辑表格和自动测算结果。
|
||||
async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceText = '', options = {}) {
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({ title: String(expenseTypeLabel || expenseType || '申请').trim().slice(0, 18) || '申请' })
|
||||
}
|
||||
const previewSourceText = String(sourceText || resolveLatestInlineUserPrompt()).trim()
|
||||
aiExpenseDraft.value = null
|
||||
assistantDraft.value = ''
|
||||
removeWorkbenchDateTag()
|
||||
closeWorkbenchDatePicker()
|
||||
clearAiModeFiles()
|
||||
if (options.pushUserMessage !== false) {
|
||||
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
|
||||
}
|
||||
const preview = await refreshApplicationPreviewEstimate(
|
||||
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText)
|
||||
)
|
||||
const content = buildLocalApplicationPreviewMessage(preview)
|
||||
conversationMessages.value.push(createInlineMessage('assistant', content, {
|
||||
applicationPreview: preview,
|
||||
text: content
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user