Files
X-Financial/web/src/components/business/PersonalWorkbenchAiMode.vue
caoxiaozhu 0cde1f8990 feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
2026-06-18 22:12:24 +08:00

1667 lines
60 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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-reply"></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"
v-html="renderInlineMarkdown(message.content)"
></div>
<div v-else-if="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 { renderMarkdown } from '../../utils/markdown.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 {
applyAiApplicationAnswer,
buildAiApplicationStepPrompt,
buildAiApplicationSummary,
createAiApplicationDraft,
isAiApplicationDraftComplete
} from '../../utils/aiApplicationDraftModel.js'
import {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
import { 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 { 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 aiApplicationDraft = 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 {
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 : [],
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 : []
})
}
function serializeRuntimeMessage(message = {}) {
return {
id: message.id,
role: message.role,
content: message.content,
feedback: message.feedback || '',
stewardPlan: message.stewardPlan || null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : []
}
}
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 renderInlineMarkdown(content) {
return renderMarkdown(content)
}
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) {
const flow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
if (!flow) {
return false
}
if (flow.flowId === 'travel_application') {
aiExpenseDraft.value = null
startAiApplicationDraft('travel', '差旅费')
return true
}
if (flow.flowId === 'travel_reimbursement') {
aiApplicationDraft.value = null
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
}
}
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 {
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)) {
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 (aiApplicationDraft.value && !isAiApplicationDraftComplete(aiApplicationDraft.value)) {
advanceAiApplicationDraft(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() || '差旅费'
startAiApplicationDraft(expenseType, expenseTypeLabel)
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()
startAiApplicationDraft(expenseType, expenseTypeLabel)
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 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()
}
// 进入申请草稿:在当前 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)))
}
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>