feat(web): 工作台 AI 模式报销预审与文档查询模型拆分

- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出
- PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示
- 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试
- 新增 AI 文档卡片背景资源
This commit is contained in:
caoxiaozhu
2026-06-20 10:17:37 +08:00
parent 3d69f8501f
commit 304bbe1fd4
26 changed files with 3974 additions and 117 deletions

View File

@@ -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()
}