Files
X-Financial/web/src/components/business/PersonalWorkbenchAiMode.vue

2094 lines
77 KiB
Vue
Raw Normal View History

<template>
<section
class="workbench-ai-mode"
:class="{ 'has-conversation': conversationStarted }"
aria-label="小财管家 AI 模式"
>
<input
ref="fileInputRef"
class="workbench-ai-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleAiModeFilesChange"
/>
<Transition name="workbench-ai-panel-swap" mode="out-in" appear>
<div v-if="!conversationStarted" key="welcome" class="workbench-ai-shell workbench-ai-home">
<div class="workbench-ai-orb" aria-hidden="true">
<img
:src="orbIcon"
class="workbench-ai-orb__image"
alt=""
/>
</div>
<div class="workbench-ai-copy">
<h2>{{ displayUserName }}我是您的小财管家</h2>
<p>您可以直接向我提问或选择下方推荐主题快速完成费用相关事务</p>
</div>
<form class="workbench-ai-composer" @submit.prevent="submitAiModePrompt">
<div class="workbench-ai-composer-field">
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button type="button" aria-label="移除日期" @click="removeWorkbenchDateTag">
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="3"
placeholder="今天我能帮您做点什么?"
@keydown.enter.exact.prevent="submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: workbenchDateMode === 'single' }"
@click="setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: workbenchDateMode === 'range' }"
@click="setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="workbenchSingleDate"
type="date"
@change="handleWorkbenchDateInputChange('single')"
/>
</label>
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="workbenchRangeStartDate"
type="date"
@change="handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="workbenchRangeEndDate"
type="date"
@change="handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button type="button" class="ghost" @click="clearWorkbenchDateSelection">清除</button>
<button
type="button"
class="primary"
:disabled="!workbenchCanApplyDateSelection"
@click="applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
@click="triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
@click="handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
<span>{{ displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!canSubmitAiModePrompt || sending"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
<div v-if="selectedFiles.length" class="workbench-ai-file-strip">
<span>已选择 {{ selectedFiles.length }} 份附件</span>
<button type="button" @click="clearAiModeFiles">清空</button>
</div>
<div class="workbench-ai-quick-start-section">
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
<div class="workbench-ai-action-row" aria-label="推荐主题">
<button
v-for="item in aiModeActionItems"
:key="item.label"
type="button"
class="workbench-ai-action"
@click="runAiModeAction(item)"
>
<div class="action-icon-wrapper">
<i :class="item.icon"></i>
</div>
<div class="action-text">
<strong>{{ item.label }}</strong>
<p>{{ item.prompt }}</p>
</div>
</button>
</div>
</div>
</div>
<div v-else key="conversation" class="workbench-ai-conversation">
<div class="workbench-ai-conversation-actions" aria-label="对话操作">
<button type="button" title="回到对话顶部" aria-label="回到对话顶部" @click="scrollInlineConversationToTop">
<i class="mdi mdi-arrow-up"></i>
</button>
<button
type="button"
class="danger"
title="删除当前对话"
aria-label="删除当前对话"
:disabled="!conversationMessages.length"
@click="requestDeleteCurrentConversation"
>
<i class="mdi mdi-trash-can-outline"></i>
</button>
</div>
<div
ref="conversationScrollRef"
class="workbench-ai-thread"
aria-live="polite"
@scroll.passive="handleInlineConversationScroll"
>
<div v-if="conversationMessages.length === 0" class="workbench-ai-empty-thread">
<strong>{{ activeConversationTitle || '新对话' }}</strong>
<p>直接输入问题小财管家会在当前页面内持续回复</p>
</div>
<article
v-for="message in conversationMessages"
:key="message.id"
class="workbench-ai-message"
:class="`is-${message.role}`"
>
<div v-if="message.role === 'user'" class="workbench-ai-user-bubble">
{{ message.content }}
</div>
<div v-if="message.role === 'user'" class="workbench-ai-message-actions">
<button type="button" title="引用" aria-label="引用" @click="quoteInlineMessage(message)">
<i class="mdi mdi-format-quote-open"></i>
</button>
<button type="button" title="复制" aria-label="复制" @click="copyInlineMessage(message)">
<i class="mdi mdi-content-copy"></i>
</button>
<time class="workbench-ai-message-time">{{ formatMessageTime(message.createdAt) }}</time>
</div>
<template v-else>
<div
class="workbench-ai-answer-card"
:class="{ pending: message.pending, 'has-thinking': hasInlineThinking(message) }"
>
<div
v-if="hasInlineThinking(message)"
class="workbench-ai-thinking-panel"
:class="{
'is-expanded': isInlineThinkingExpanded(message),
'is-collapsed': !isInlineThinkingExpanded(message),
'is-running': message.pending
}"
>
<button
v-if="!isInlineThinkingExpanded(message)"
type="button"
class="workbench-ai-thinking-toggle"
aria-expanded="false"
@click="toggleInlineThinking(message)"
>
<span class="workbench-ai-thinking-toggle-left">
<span class="workbench-ai-thinking-dot" aria-hidden="true"></span>
<strong>小财业务思考</strong>
<small>{{ resolveInlineThinkingEvents(message).length }} </small>
</span>
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
</button>
<div v-else class="workbench-ai-thinking-expanded">
<button
type="button"
class="workbench-ai-thinking-collapse-btn"
aria-label="折叠小财业务思考"
@click="toggleInlineThinking(message)"
>
<i class="mdi mdi-chevron-up" aria-hidden="true"></i>
</button>
<Transition name="workbench-ai-thinking-collapse" appear>
<div
class="workbench-ai-thinking-list"
aria-label="小财业务思考明细"
>
<div
v-for="event in resolveInlineThinkingEvents(message)"
:key="event.eventId || `${message.id}-${event.title}`"
class="workbench-ai-thinking-item"
:class="`is-${event.status || 'completed'}`"
>
<span class="workbench-ai-thinking-dot" aria-hidden="true"></span>
<div>
<strong>{{ event.title || '正在分析' }}</strong>
<p v-if="event.content">{{ event.content }}</p>
</div>
</div>
</div>
</Transition>
</div>
</div>
<div
v-if="message.content"
class="workbench-ai-answer-markdown"
@click.capture="handleAiAnswerMarkdownClick($event)"
v-html="renderInlineConversationHtml(message.content)"
></div>
<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>
<div v-if="message.suggestedActions?.length" class="workbench-ai-suggested-actions">
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.label}`"
type="button"
@click="handleInlineSuggestedAction(action)"
>
<i :class="action.icon || 'mdi mdi-arrow-right-circle-outline'"></i>
<span>{{ action.label }}</span>
</button>
</div>
</div>
<div v-if="!message.pending" class="workbench-ai-message-actions" aria-label="消息操作">
<button type="button" title="复制" aria-label="复制" @click="copyInlineMessage(message)">
<i class="mdi mdi-content-copy"></i>
</button>
<button type="button" title="有帮助" aria-label="有帮助" @click="markInlineMessageFeedback(message, 'up')">
<i class="mdi mdi-thumb-up-outline"></i>
</button>
<button type="button" title="无帮助" aria-label="无帮助" @click="markInlineMessageFeedback(message, 'down')">
<i class="mdi mdi-thumb-down-outline"></i>
</button>
<button type="button" title="重新生成" aria-label="重新生成" @click="regenerateLastReply">
<i class="mdi mdi-refresh"></i>
</button>
<time class="workbench-ai-message-time">{{ formatMessageTime(message.createdAt) }}</time>
</div>
</template>
</article>
</div>
<div class="workbench-ai-conversation-bottom">
<div v-if="selectedFiles.length" class="workbench-ai-file-strip inline">
<span>已选择 {{ selectedFiles.length }} 份附件</span>
<button type="button" @click="clearAiModeFiles">清空</button>
</div>
<form class="workbench-ai-composer workbench-ai-composer--inline" @submit.prevent="submitAiModePrompt">
<div class="workbench-ai-composer-field">
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
<i class="mdi mdi-calendar-check-outline"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button type="button" aria-label="移除日期" @click="removeWorkbenchDateTag">
<i class="mdi mdi-close"></i>
</button>
</div>
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="3"
placeholder="继续和小财管家对话..."
@keydown.enter.exact.prevent="submitAiModePrompt"
></textarea>
</div>
<div class="workbench-ai-composer-toolbar">
<div class="workbench-ai-tool-buttons">
<div class="workbench-date-anchor workbench-ai-date-anchor">
<button
type="button"
class="workbench-ai-icon-btn"
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="workbench-ai-date-popover"
role="dialog"
aria-label="选择业务日期"
@click.stop
>
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
<button
type="button"
:class="{ active: workbenchDateMode === 'single' }"
@click="setWorkbenchDateMode('single')"
>
单日
</button>
<button
type="button"
:class="{ active: workbenchDateMode === 'range' }"
@click="setWorkbenchDateMode('range')"
>
范围
</button>
</div>
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
<span>业务日期</span>
<input
v-model="workbenchSingleDate"
type="date"
@change="handleWorkbenchDateInputChange('single')"
/>
</label>
<div v-else class="workbench-ai-date-range">
<label class="workbench-ai-date-field">
<span>开始日期</span>
<input
v-model="workbenchRangeStartDate"
type="date"
@change="handleWorkbenchDateInputChange('range-start')"
/>
</label>
<label class="workbench-ai-date-field">
<span>结束日期</span>
<input
v-model="workbenchRangeEndDate"
type="date"
@change="handleWorkbenchDateInputChange('range-end')"
/>
</label>
</div>
<div class="workbench-ai-date-actions">
<button type="button" class="ghost" @click="clearWorkbenchDateSelection">清除</button>
<button
type="button"
class="primary"
:disabled="!workbenchCanApplyDateSelection"
@click="applyWorkbenchDateSelection"
>
完成
</button>
</div>
</div>
</div>
<button
type="button"
class="workbench-ai-icon-btn"
title="上传附件"
aria-label="上传附件"
@click="triggerAiModeFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="workbench-ai-icon-btn"
title="语音输入"
aria-label="语音输入"
@click="handleVoiceInput"
>
<i class="mdi mdi-microphone-outline"></i>
</button>
</div>
<div class="workbench-ai-composer-right">
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
<span>{{ displayModelName }}</span>
<i class="mdi mdi-chevron-down"></i>
</div>
<button
type="submit"
class="workbench-ai-send-btn"
:disabled="!canSubmitAiModePrompt || sending"
aria-label="发送给小财管家"
>
<i class="mdi mdi-arrow-up"></i>
</button>
</div>
</div>
</form>
<p class="workbench-ai-disclaimer">小财管家可能会出错重要费用事项请核对单据制度与审批结果</p>
</div>
</div>
</Transition>
<Transition name="workbench-ai-confirm-fade">
<div v-if="deleteDialogOpen" class="workbench-ai-confirm-mask" role="presentation" @click.self="cancelDeleteConversation">
<div
class="workbench-ai-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="workbench-ai-delete-title"
>
<h3 id="workbench-ai-delete-title">删除当前对话</h3>
<p>删除后左侧最近对话中的这条记录也会被移除这个操作无法恢复</p>
<div class="workbench-ai-confirm-actions">
<button type="button" class="ghost" @click="cancelDeleteConversation">取消</button>
<button type="button" class="danger" @click="confirmDeleteConversation">删除对话</button>
</div>
</div>
</div>
</Transition>
</section>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { fetchSettings } from '../../services/settings.js'
import { fetchStewardPlan, fetchStewardPlanStream } from '../../services/steward.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
import {
deleteAiWorkbenchConversation,
loadAiWorkbenchConversationHistory,
saveAiWorkbenchConversation
} from '../../utils/aiWorkbenchConversationStore.js'
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import {
buildExpenseSceneSelectionActions
} from '../../utils/expenseAssistantActions.js'
import {
buildStewardPlanMessageText,
buildStewardPlanRequest,
buildStewardSuggestedActions,
normalizeStewardPlan
} from '../../views/scripts/stewardPlanModel.js'
import {
buildExpenseSceneSelectionMessage,
SESSION_TYPE_EXPENSE
} from '../../views/scripts/travelReimbursementConversationModel.js'
import {
applyAiExpenseAnswer,
buildAiExpenseStepPrompt,
buildAiExpenseSummary,
createAiExpenseDraft,
isAiExpenseDraftComplete
} from '../../utils/aiExpenseDraftModel.js'
import {
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 {
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', 'open-document'])
const AI_DOCUMENT_QUERY_STEP_DELAY_MS = 320
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
const assistantInputRef = ref(null)
const fileInputRef = ref(null)
const conversationScrollRef = ref(null)
const inlineConversationAutoScrollPinned = ref(true)
const selectedFiles = ref([])
const systemSettings = ref(null)
const conversationStarted = ref(false)
const conversationMessages = ref([])
const conversationId = ref('')
const activeConversationTitle = ref('')
const sending = ref(false)
const stewardState = ref(null)
const aiExpenseDraft = ref(null)
const thinkingExpandedMessageIds = ref(new Set())
const thinkingCollapsedMessageIds = ref(new Set())
const deleteDialogOpen = ref(false)
let messageSeq = 0
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
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,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
applyWorkbenchDateSelection,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
const aiModeActionItems = [
{
label: '发起报销',
icon: 'mdi mdi-file-document-plus-outline',
prompt: '帮我发起一笔报销,并检查需要准备哪些票据材料。',
source: 'workbench',
sessionType: 'expense'
},
{
label: '查询预算',
icon: 'mdi mdi-chart-pie-outline',
prompt: '帮我查询当前预算余额和近期费用占用情况。',
source: 'budget',
sessionType: 'budget'
},
{
label: '解释制度',
icon: 'mdi mdi-book-open-page-variant-outline',
prompt: '帮我解释公司报销制度,并列出这次需要注意的条款。',
source: 'workbench',
sessionType: 'knowledge'
},
{
label: '催办审批',
icon: 'mdi mdi-bell-ring-outline',
prompt: '帮我查询待审批单据,并生成一段礼貌的催办说明。',
source: 'workbench',
sessionType: 'approval'
}
]
const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
const displayModelName = computed(() => {
const llmForm = systemSettings.value?.llmForm
if (!llmForm) return 'Axiom Ultra 3.1'
const model = llmForm.mainModel || ''
const provider = llmForm.mainProvider || ''
if (!model) return 'Axiom Ultra 3.1'
return provider ? `${provider} / ${model}` : model
})
const modelSelectorTitle = computed(() => {
const llmForm = systemSettings.value?.llmForm
if (!llmForm) return '当前模型Axiom Ultra 3.1'
const model = llmForm.mainModel || 'Axiom Ultra 3.1'
const provider = llmForm.mainProvider || ''
return provider ? `当前模型:${provider} / ${model}` : `当前模型:${model}`
})
const canSubmitAiModePrompt = computed(() => (
Boolean(assistantDraft.value.trim())
|| selectedFiles.value.length > 0
|| Boolean(workbenchDateTagLabel.value)
))
async function loadSystemSettings() {
try {
systemSettings.value = await fetchSettings()
} catch {
systemSettings.value = { llmForm: {} }
}
}
function focusAiModeInput() {
nextTick(() => {
assistantInputRef.value?.focus()
})
}
function isInlineConversationNearBottom() {
const el = conversationScrollRef.value
if (!el) {
return true
}
return el.scrollHeight - el.clientHeight - el.scrollTop <= INLINE_AUTO_SCROLL_THRESHOLD
}
function handleInlineConversationScroll() {
inlineConversationAutoScrollPinned.value = isInlineConversationNearBottom()
}
function forceInlineConversationToBottom() {
const el = conversationScrollRef.value
if (el) {
el.scrollTop = el.scrollHeight
inlineConversationAutoScrollPinned.value = true
}
}
function scrollInlineConversationToBottom(options = {}) {
const shouldScroll = options.force !== false
nextTick(() => {
if (!shouldScroll) {
return
}
forceInlineConversationToBottom()
window.requestAnimationFrame(() => {
forceInlineConversationToBottom()
})
window.setTimeout(() => {
if (inlineConversationAutoScrollPinned.value) {
forceInlineConversationToBottom()
}
}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS)
})
}
function scrollInlineConversationToTop() {
nextTick(() => {
const el = conversationScrollRef.value
if (el) {
inlineConversationAutoScrollPinned.value = false
el.scrollTo({ top: 0, behavior: 'smooth' })
}
})
}
function normalizeParagraphs(content) {
return String(content || '')
.split(/\n{2,}|\n/)
.map((item) => item.trim())
.filter(Boolean)
}
function createInlineMessage(role, content, options = {}) {
const normalizedContent = String(content || '').trim()
return {
id: options.id || `${Date.now()}-${messageSeq += 1}`,
role,
content: normalizedContent,
paragraphs: normalizeParagraphs(normalizedContent),
pending: Boolean(options.pending),
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()
}
}
function formatMessageTime(timestamp) {
if (!timestamp) return ''
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function updateInlineMessageContent(message, content) {
if (!message) {
return
}
message.content = String(content || '')
message.paragraphs = normalizeParagraphs(message.content)
}
function appendInlineMessageContent(message, delta) {
const nextDelta = String(delta || '')
if (!nextDelta) {
return
}
updateInlineMessageContent(message, `${message.content || ''}${nextDelta}`)
}
function waitInlineAnswerStreamFrame() {
return new Promise((resolve) => {
window.setTimeout(resolve, INLINE_ANSWER_STREAM_DELAY_MS)
})
}
async function streamInlineAssistantContent(messageId, content) {
const targetContent = String(content || '').trim()
let streamedContent = ''
for (let index = 0; index < targetContent.length; index += INLINE_ANSWER_STREAM_CHUNK_SIZE) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message || !message.pending) {
return
}
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
streamedContent += targetContent.slice(index, index + INLINE_ANSWER_STREAM_CHUNK_SIZE)
updateInlineMessageContent(message, streamedContent)
scrollInlineConversationToBottom({ force: shouldAutoScroll })
await waitInlineAnswerStreamFrame()
}
}
function normalizeRuntimeMessage(message = {}) {
return createInlineMessage(message.role || 'assistant', message.content || '', {
id: message.id,
pending: false,
feedback: message.feedback || '',
stewardPlan: message.stewardPlan || null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
applicationPreview: message.applicationPreview || null,
text: message.text || message.content || ''
})
}
function serializeRuntimeMessage(message = {}) {
return {
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 : [],
applicationPreview: message.applicationPreview || null
}
}
function refreshConversationHistory() {
const history = loadAiWorkbenchConversationHistory(currentUser.value || {})
emit('conversation-history-change', history)
return history
}
function isPersistableInlineConversation() {
return Boolean(
conversationId.value &&
conversationId.value !== AI_SEARCH_CONVERSATION_ID &&
conversationMessages.value.length
)
}
function persistCurrentConversation() {
if (!isPersistableInlineConversation()) {
refreshConversationHistory()
return []
}
const history = saveAiWorkbenchConversation(currentUser.value || {}, {
id: conversationId.value,
conversationId: conversationId.value,
title: activeConversationTitle.value,
source: 'workbench',
sessionType: 'steward',
stewardState: stewardState.value,
messages: conversationMessages.value.map((message) => serializeRuntimeMessage(message))
})
emit('conversation-history-change', history)
return history
}
function resetInlineConversationState() {
conversationStarted.value = false
conversationMessages.value = []
conversationId.value = ''
stewardState.value = null
activeConversationTitle.value = ''
assistantDraft.value = ''
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
deleteDialogOpen.value = false
clearWorkbenchDateSelection()
clearAiModeFiles()
}
function replaceInlineMessage(id, nextMessage) {
const index = conversationMessages.value.findIndex((item) => item.id === id)
if (index === -1) {
conversationMessages.value.push(nextMessage)
return
}
conversationMessages.value.splice(index, 1, nextMessage)
}
function activateInlineConversation(options = {}) {
conversationStarted.value = true
if (!conversationId.value) {
conversationId.value = options.id || `inline-${Date.now()}`
}
activeConversationTitle.value = options.title || activeConversationTitle.value || '新对话'
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
}
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) {
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
}
function hasInlineThinking(message) {
return resolveInlineThinkingEvents(message).length > 0
}
function isInlineThinkingExpanded(message) {
if (!message?.id) {
return Boolean(message?.pending)
}
if (thinkingCollapsedMessageIds.value.has(message.id)) {
return false
}
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
}
function toggleInlineThinking(message) {
if (!message?.id) {
return
}
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
if (isInlineThinkingExpanded(message)) {
nextExpandedIds.delete(message.id)
nextCollapsedIds.add(message.id)
} else {
nextCollapsedIds.delete(message.id)
nextExpandedIds.add(message.id)
}
thinkingExpandedMessageIds.value = nextExpandedIds
thinkingCollapsedMessageIds.value = nextCollapsedIds
}
function buildInlinePromptText(rawPrompt, files = []) {
const prompt = buildWorkbenchPromptText(rawPrompt)
if (prompt) {
return prompt
}
return files.length ? '请帮我处理已上传的附件。' : ''
}
function shouldCheckAiRequiredApplicationGate(prompt) {
const compact = String(prompt || '').replace(/\s+/g, '')
if (!compact || !/(出差|差旅|部署|实施|支撑|支持|协助|拜访|调研|驻场|上线|验收)/.test(compact)) {
return false
}
if (!/\d{1,2}月\d{1,2}|昨天|前天|上周|上月/.test(compact)) {
return false
}
return !/(申请|报销|草稿|提交|审批|保存|发起|创建)/.test(compact)
}
function serializeRequiredApplicationCandidate(candidate = {}) {
return {
id: String(candidate.id || '').trim(),
claim_no: String(candidate.claim_no || '').trim(),
reason: String(candidate.reason || '').trim(),
location: String(candidate.location || '').trim(),
business_time: String(candidate.business_time || '').trim(),
status_label: String(candidate.status_label || '').trim()
}
}
async function attachAiRequiredApplicationGate(planRequest, prompt) {
if (!shouldCheckAiRequiredApplicationGate(prompt)) {
return planRequest
}
try {
const claims = await fetchExpenseClaims()
const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
planRequest.context_json = {
...(planRequest.context_json || {}),
required_application_gate: {
...((planRequest.context_json || {}).required_application_gate || {}),
travel: {
checked: true,
candidate_count: candidates.length,
candidates: candidates.slice(0, 5).map((candidate) => serializeRequiredApplicationCandidate(candidate))
}
}
}
} catch (error) {
console.warn('AI mode required application lookup failed:', error)
planRequest.context_json = {
...(planRequest.context_json || {}),
required_application_gate: {
...((planRequest.context_json || {}).required_application_gate || {}),
travel: {
checked: false,
query_failed: true
}
}
}
}
return planRequest
}
function resolveRequiredApplicationGateContinuationFlow(normalizedPlan) {
if (String(normalizedPlan?.pendingFlowConfirmation?.status || '').trim() !== 'pending') {
return null
}
const flows = Array.isArray(normalizedPlan?.candidateFlows) ? normalizedPlan.candidateFlows : []
const applicationFlow = flows.find((flow) => flow.flowId === 'travel_application')
if (flows.length === 1 && applicationFlow && /先发起出差申请/.test(applicationFlow.label)) {
return applicationFlow
}
return flows.find((flow) => (
flow.flowId === 'travel_reimbursement' &&
/关联已有申请单/.test(flow.label)
)) || null
}
function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
const baseText = buildStewardPlanMessageText({
planStatus: normalizedPlan?.planStatus,
nextAction: normalizedPlan?.nextAction,
summary: normalizedPlan?.summary,
pendingFlowConfirmation: normalizedPlan?.pendingFlowConfirmation,
candidateFlows: normalizedPlan?.candidateFlows
})
const contextText = String(baseText || '')
.split(/\n\n1\. \*\*/)[0]
.trim()
.replace('### 需要先确认流程方向', '### 我已先查询申请单')
if (flow?.flowId === 'travel_application') {
return [
contextText || baseText,
'我会继续在当前对话里为你发起出差申请。'
].filter(Boolean).join('\n\n')
}
if (flow?.flowId === 'travel_reimbursement') {
return [
contextText || baseText,
'我会继续进入申请单关联步骤,请你确认要关联哪张单据。'
].filter(Boolean).join('\n\n')
}
return baseText
}
function continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt = '') {
const flow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
if (!flow) {
return false
}
if (flow.flowId === 'travel_application') {
aiExpenseDraft.value = null
void startAiApplicationPreview('travel', '差旅费', prompt)
return true
}
if (flow.flowId === 'travel_reimbursement') {
startAiExpenseDraft('travel', '差旅费', true)
return true
}
return false
}
function normalizeStreamThinkingEvent(event = {}) {
const data = event?.data && typeof event.data === 'object' ? event.data : {}
const eventId = String(data.event_id || data.eventId || data.stage || `thinking-${Date.now()}`).trim()
return {
eventId,
stage: String(data.stage || '').trim(),
title: String(data.title || '小财管家正在分析').trim(),
content: String(data.content || '').trim(),
status: String(data.status || 'running').trim() || 'running'
}
}
function handleInlineStewardStreamEvent(messageId, event) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message) {
return
}
if (event?.event === 'answer_delta') {
const data = event?.data && typeof event.data === 'object' ? event.data : {}
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
appendInlineMessageContent(message, data.delta || data.content || data.text || '')
message.stewardPlan = {
...(message.stewardPlan || {}),
streamStatus: 'streaming'
}
scrollInlineConversationToBottom({ force: shouldAutoScroll })
return
}
if (event?.event !== 'thinking') {
return
}
const nextEvent = normalizeStreamThinkingEvent(event)
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
const currentPlan = message.stewardPlan || {}
const currentEvents = Array.isArray(currentPlan.thinkingEvents) ? currentPlan.thinkingEvents : []
const eventIndex = currentEvents.findIndex((item) => item.eventId && item.eventId === nextEvent.eventId)
const nextEvents = eventIndex >= 0
? currentEvents.map((item, index) => (index === eventIndex ? { ...item, ...nextEvent } : item))
: [...currentEvents, nextEvent]
message.stewardPlan = {
...currentPlan,
thinkingEvents: nextEvents,
streamStatus: 'streaming'
}
scrollInlineConversationToBottom({ force: shouldAutoScroll })
}
async function fetchInlineStewardPlan(messageId, payload) {
try {
return await fetchStewardPlanStream(
payload,
{
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event)
},
{
idleTimeoutMs: 90000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
}
)
} catch (error) {
if (String(error?.message || '').includes('流式服务')) {
return fetchStewardPlan(payload, {
timeoutMs: 75000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
})
}
throw error
}
}
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', '', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: [
{
eventId: 'init',
title: '小财管家正在接入业务流程',
content: '正在识别你的意图、上下文和附件信息。',
status: 'running'
}
]
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
return
}
const planRequest = buildStewardPlanRequest({
rawText: prompt,
files,
currentUser: currentUser.value || {},
conversationId: conversationId.value,
stewardState: stewardState.value
})
planRequest.context_json = {
...planRequest.context_json,
entry_source: 'workbench_ai_inline',
source: entry.source || 'workbench'
}
await attachAiRequiredApplicationGate(planRequest, prompt)
const plan = await fetchInlineStewardPlan(pendingMessage.id, planRequest)
const normalizedPlan = normalizeStewardPlan(plan, {
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true
})
const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
const nextThinkingEvents = normalizedPlan.thinkingEvents.length
? normalizedPlan.thinkingEvents
: previousThinkingEvents.map((item) => ({ ...item, status: 'completed' }))
const previousConversationId = conversationId.value
const nextConversationId = String(normalizedPlan.conversationId || '').trim()
if (nextConversationId) {
conversationId.value = nextConversationId
emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
if (previousConversationId && previousConversationId !== nextConversationId) {
deleteAiWorkbenchConversation(currentUser.value || {}, previousConversationId)
}
}
if (normalizedPlan.stewardState) {
stewardState.value = normalizedPlan.stewardState
}
const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
const finalMessageText = requiredApplicationContinuationFlow
? buildAiRequiredApplicationGateAutoMessage(normalizedPlan, requiredApplicationContinuationFlow)
: buildStewardPlanMessageText(plan)
const hasServerStreamedContent = Boolean(String(pendingMessage.content || '').trim())
if (!hasServerStreamedContent) {
await streamInlineAssistantContent(pendingMessage.id, finalMessageText)
}
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', finalMessageText, {
id: pendingMessage.id,
stewardPlan: {
...normalizedPlan,
thinkingEvents: nextThinkingEvents,
streamStatus: 'completed'
},
suggestedActions: requiredApplicationContinuationFlow ? [] : buildStewardSuggestedActions(plan)
})
)
if (continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt)) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
}
persistCurrentConversation()
} catch (error) {
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
replaceInlineMessage(
pendingMessage.id,
createInlineMessage(
'assistant',
error?.message || '小财管家暂时无法完成规划,请稍后再试。',
{
id: pendingMessage.id,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
...item,
status: 'failed'
}))
}
}
)
)
toast(error?.message || '小财管家暂时无法完成规划。')
persistCurrentConversation()
} finally {
sending.value = false
scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
}
}
function startInlineConversation(prompt, entry = {}, files = []) {
const cleanPrompt = buildInlinePromptText(prompt, files)
if (!cleanPrompt || sending.value) {
return
}
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
advanceAiExpenseDraft(cleanPrompt, files)
return
}
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
conversationId.value = ''
conversationMessages.value = []
activeConversationTitle.value = ''
}
sending.value = true
activateInlineConversation({
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
})
inlineConversationAutoScrollPinned.value = true
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
clearAiModeFiles()
scrollInlineConversationToBottom()
persistCurrentConversation()
void requestInlineAssistantReply(cleanPrompt, entry, files)
}
function submitAiModePrompt() {
if (!canSubmitAiModePrompt.value) {
toast('请输入需求后再发送。')
focusAiModeInput()
return
}
startInlineConversation(assistantDraft.value, { source: 'workbench', sessionType: 'steward' }, Array.from(selectedFiles.value))
}
function runAiModeAction(item) {
if (String(item?.label || '').trim() === '发起报销') {
pushInlineExpenseSceneSelectionPrompt(item.prompt, item.label)
return
}
startInlineConversation(item.prompt, item, Array.from(selectedFiles.value))
}
function startNewInlineConversation() {
resetInlineConversationState()
emit('conversation-change', { id: '', title: '' })
refreshConversationHistory()
focusAiModeInput()
}
function openInlineSearchConversation() {
conversationMessages.value = [
createInlineMessage('assistant', '你可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
]
stewardState.value = null
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
conversationId.value = AI_SEARCH_CONVERSATION_ID
activateInlineConversation({ id: AI_SEARCH_CONVERSATION_ID, title: '查询对话' })
focusAiModeInput()
scrollInlineConversationToBottom()
}
function openInlineRecentConversation(item = {}) {
const title = String(item.title || '最近对话').trim()
conversationId.value = String(item.id || `recent-${Date.now()}`).trim()
activeConversationTitle.value = title
stewardState.value = item.stewardState || null
thinkingExpandedMessageIds.value = new Set()
thinkingCollapsedMessageIds.value = new Set()
inlineConversationAutoScrollPinned.value = true
conversationMessages.value = Array.isArray(item.messages) && item.messages.length
? item.messages.map((message) => normalizeRuntimeMessage(message))
: [
createInlineMessage(
'assistant',
'这条历史对话没有保存完整消息。你可以继续输入新的问题,小财管家会接着处理。'
)
]
conversationStarted.value = true
emit('conversation-change', { id: conversationId.value, title })
focusAiModeInput()
scrollInlineConversationToBottom()
}
function regenerateLastReply() {
const lastUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
if (!lastUserMessage || sending.value) {
return
}
const lastAssistantIndex = conversationMessages.value.map((message) => message.role).lastIndexOf('assistant')
if (lastAssistantIndex >= 0) {
conversationMessages.value.splice(lastAssistantIndex, 1)
}
sending.value = true
void requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
}
function handleInlineSuggestedAction(action = {}) {
const prefillText = resolveSuggestedActionPrefill(action)
if (prefillText) {
assistantDraft.value = mergeComposerPrefill(assistantDraft.value, prefillText)
focusAiModeInput()
return
}
const actionType = String(action?.action_type || '').trim()
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
startAiExpenseDraft(expenseType, expenseTypeLabel, true)
return
}
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
aiExpenseDraft.value = null
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
void startAiApplicationPreview(
expenseType,
expenseTypeLabel,
actionPayload.carry_text || resolveLatestInlineUserPrompt()
)
return
}
if (actionType === 'select_expense_type') {
const expenseType = String(action?.payload?.expense_type || '').trim()
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
const requiresApplicationBeforeReimbursement = Boolean(action?.payload?.requires_application_before_reimbursement)
startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement)
return
}
if (actionType === 'select_required_application') {
linkAiExpenseApplication(action?.payload || {})
return
}
if (actionType === 'ai_application_start_inline') {
aiExpenseDraft.value = null
const expenseType = String(action?.payload?.expense_type || '').trim()
const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
void startAiApplicationPreview(
expenseType,
expenseTypeLabel,
action?.payload?.carry_text || resolveLatestInlineUserPrompt()
)
return
}
const carryText = String(action?.payload?.carry_text || action?.label || '').trim()
if (!carryText) {
return
}
if (String(action?.payload?.session_type || '').trim() === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
pushInlineExpenseSceneSelectionPrompt(carryText, action.label)
return
}
startInlineConversation(carryText, {
label: action.label,
source: 'steward-action',
sessionType: action?.payload?.session_type || 'steward'
}, action?.payload?.carry_files ? Array.from(selectedFiles.value) : [])
}
function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') {
const sourceText = String(originalMessage || '我要报销').trim()
if (!conversationStarted.value) {
activateInlineConversation({
title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销'
})
}
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
conversationMessages.value.push(createInlineMessage('user', String(selectedLabel || sourceText).trim()))
conversationMessages.value.push(createInlineMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), {
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
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) {
if (!conversationStarted.value) {
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
}
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
clearAiModeFiles()
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
if (requiresApplicationBeforeReimbursement) {
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel)
return
}
const draft = createAiExpenseDraft(expenseType, expenseTypeLabel)
aiExpenseDraft.value = draft
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function advanceAiExpenseDraft(answer, files = []) {
const fileNames = Array.from(files || [])
pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : ''))
assistantDraft.value = ''
clearAiModeFiles()
const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames)
aiExpenseDraft.value = next
if (isAiExpenseDraftComplete(next)) {
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改直接告诉我确认无误后我再帮你生成报销草稿。`))
aiExpenseDraft.value = null
} else {
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
}
persistCurrentConversation()
scrollInlineConversationToBottom()
}
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
let claims = null
try {
claims = await fetchExpenseClaims()
} catch {
aiExpenseDraft.value = null
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
persistCurrentConversation()
scrollInlineConversationToBottom()
return
}
const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {})
aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel)
if (!candidates.length) {
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
suggestedActions: [{
label: '在当前对话里发起申请',
description: '逐项收集出差申请要点,整理后你可以提交到申请助手',
icon: 'mdi mdi-file-plus-outline',
action_type: 'ai_application_start_inline',
payload: {
expense_type: expenseType,
expense_type_label: expenseTypeLabel
}
}]
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
return
}
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationSelectionText(expenseType, candidates), {
suggestedActions: buildRequiredApplicationActions(candidates, 'select_required_application')
}))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
function linkAiExpenseApplication(application = {}) {
const draft = aiExpenseDraft.value
if (!draft) {
return
}
const claimNo = String(application.application_claim_no || '').trim()
pushInlineUserMessage(`关联申请单 ${claimNo}`.trim())
const linked = {
...draft,
applicationClaim: application,
values: {
...draft.values,
reason: String(application.application_reason || '').trim(),
location: String(application.application_location || '').trim(),
time_range: String(application.application_business_time || '').trim(),
amount: String(application.application_amount_label || application.application_amount || '').trim()
},
// 申请单已带出事由/时间/地点/金额,直接进入票据确认
stepKey: 'attachments'
}
aiExpenseDraft.value = linked
conversationMessages.value.push(createInlineMessage('assistant', [
`已关联申请单${claimNo ? ` ${claimNo}` : ''},事由、时间、地点、金额我先用申请单的内容预填了。`,
'',
'再确认一下票据:可以现在上传,或回复“稍后上传”。'
].join('\n')))
persistCurrentConversation()
scrollInlineConversationToBottom()
}
// 进入申请核对表:复用原有申请预览模型,一次性展示可编辑表格和自动测算结果。
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()
}
function requestDeleteCurrentConversation() {
if (!conversationMessages.value.length) {
return
}
deleteDialogOpen.value = true
}
function cancelDeleteConversation() {
deleteDialogOpen.value = false
}
function confirmDeleteConversation() {
const nextHistory = conversationId.value
? deleteAiWorkbenchConversation(currentUser.value || {}, conversationId.value)
: refreshConversationHistory()
emit('conversation-history-change', nextHistory)
resetInlineConversationState()
emit('conversation-change', { id: '', title: '' })
toast('已删除当前对话。')
focusAiModeInput()
}
async function copyInlineMessage(message) {
try {
await navigator.clipboard?.writeText(message.content)
toast('已复制内容。')
} catch {
toast('当前浏览器暂不支持自动复制。')
}
}
function quoteInlineMessage(message) {
const quote = `> ${message.content}\n\n`
assistantDraft.value = assistantDraft.value ? assistantDraft.value + '\n' + quote : quote
focusAiModeInput()
}
function markInlineMessageFeedback(message, feedback) {
message.feedback = feedback
persistCurrentConversation()
toast(feedback === 'up' ? '已记录有帮助反馈。' : '已记录需要改进反馈。')
}
function triggerAiModeFileUpload() {
fileInputRef.value?.click()
}
function handleAiModeFilesChange(event) {
selectedFiles.value = Array.from(event.target.files || []).slice(0, 10)
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
focusAiModeInput()
}
function clearAiModeFiles() {
selectedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function handleVoiceInput() {
toast('语音输入正在准备中,您可以先输入文字需求。')
focusAiModeInput()
}
watch(
() => props.sidebarCommand?.seq,
() => {
const command = props.sidebarCommand || {}
if (command.type === 'new-chat') {
startNewInlineConversation()
return
}
if (command.type === 'search-chat') {
openInlineSearchConversation()
return
}
if (command.type === 'open-recent') {
openInlineRecentConversation(command.payload || {})
}
}
)
onMounted(() => {
loadSystemSettings()
refreshConversationHistory()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
})
</script>
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>