2026-06-18 22:12:24 +08:00
|
|
|
|
<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"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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>
|
2026-06-20 14:42:04 +08:00
|
|
|
|
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
|
2026-06-18 22:12:24 +08:00
|
|
|
|
<i class="mdi mdi-close"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
ref="assistantInputRef"
|
|
|
|
|
|
v-model="assistantDraft"
|
|
|
|
|
|
maxlength="1000"
|
|
|
|
|
|
rows="3"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '今天我能帮您做点什么?'"
|
|
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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' }"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@click="setWorkbenchDateMode('single')"
|
|
|
|
|
|
>
|
|
|
|
|
|
单日
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:class="{ active: workbenchDateMode === 'range' }"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@click="setWorkbenchDateMode('range')"
|
|
|
|
|
|
>
|
|
|
|
|
|
范围
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
|
|
|
|
|
<span>业务日期</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="workbenchSingleDate"
|
|
|
|
|
|
type="date"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@change="handleWorkbenchDateInputChange('range-start')"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="workbench-ai-date-field">
|
|
|
|
|
|
<span>结束日期</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="workbenchRangeEndDate"
|
|
|
|
|
|
type="date"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@change="handleWorkbenchDateInputChange('range-end')"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="workbench-ai-date-actions">
|
2026-06-20 14:42:04 +08:00
|
|
|
|
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
|
2026-06-18 22:12:24 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="primary"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@click="applyWorkbenchDateSelection"
|
|
|
|
|
|
>
|
|
|
|
|
|
完成
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="workbench-ai-icon-btn"
|
|
|
|
|
|
title="上传附件"
|
|
|
|
|
|
aria-label="上传附件"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@click="triggerAiModeFileUpload"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="mdi mdi-paperclip"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="workbench-ai-icon-btn"
|
|
|
|
|
|
title="语音输入"
|
|
|
|
|
|
aria-label="语音输入"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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>
|
2026-06-20 14:42:04 +08:00
|
|
|
|
<button type="button" :disabled="isAiModeInputLocked" @click="clearAiModeFiles">清空</button>
|
2026-06-18 22:12:24 +08:00
|
|
|
|
</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"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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)">
|
2026-06-18 22:13:09 +08:00
|
|
|
|
<i class="mdi mdi-format-quote-open"></i>
|
2026-06-18 22:12:24 +08:00
|
|
|
|
</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"
|
2026-06-20 10:17:37 +08:00
|
|
|
|
@click.capture="handleAiAnswerMarkdownClick($event)"
|
|
|
|
|
|
v-html="renderInlineConversationHtml(message.content)"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
></div>
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
<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,
|
2026-06-20 14:42:04 +08:00
|
|
|
|
highlight: row.highlight,
|
|
|
|
|
|
'is-disabled': isApplicationPreviewEstimatePending(message)
|
2026-06-20 10:17:37 +08:00
|
|
|
|
}"
|
|
|
|
|
|
role="row"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:tabindex="row.editable && !isApplicationPreviewEstimatePending(message) ? 0 : -1"
|
2026-06-20 10:17:37 +08:00
|
|
|
|
:aria-label="row.editable ? `编辑${row.label}` : row.label"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
@click.stop="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
|
|
|
|
|
@keydown.enter.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
|
|
|
|
|
@keydown.space.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)"
|
2026-06-20 10:17:37 +08:00
|
|
|
|
>
|
|
|
|
|
|
<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
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isApplicationPreviewEstimatePending(message)"
|
2026-06-20 10:17:37 +08:00
|
|
|
|
@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
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isApplicationPreviewEstimatePending(message)"
|
2026-06-20 10:17:37 +08:00
|
|
|
|
@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="修改内容"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isApplicationPreviewEstimatePending(message)"
|
2026-06-20 10:17:37 +08:00
|
|
|
|
@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"
|
|
|
|
|
|
>
|
2026-06-18 22:12:24 +08:00
|
|
|
|
小财管家正在识别任务、拆解流程并准备下一步建议...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
<div v-if="canShowInlineSuggestedActions(message)" class="workbench-ai-suggested-actions">
|
2026-06-18 22:12:24 +08:00
|
|
|
|
<button
|
|
|
|
|
|
v-for="action in message.suggestedActions"
|
|
|
|
|
|
:key="`${message.id}-${action.label}`"
|
|
|
|
|
|
type="button"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isInlineSuggestedActionDisabled(action, message)"
|
|
|
|
|
|
@click="handleInlineSuggestedAction(action, message)"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
2026-06-20 14:42:04 +08:00
|
|
|
|
<button type="button" :disabled="isAiModeInputLocked" @click="clearAiModeFiles">清空</button>
|
2026-06-18 22:12:24 +08:00
|
|
|
|
</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>
|
2026-06-20 14:42:04 +08:00
|
|
|
|
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
|
2026-06-18 22:12:24 +08:00
|
|
|
|
<i class="mdi mdi-close"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
ref="assistantInputRef"
|
|
|
|
|
|
v-model="assistantDraft"
|
|
|
|
|
|
maxlength="1000"
|
|
|
|
|
|
rows="3"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '继续和小财管家对话...'"
|
|
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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' }"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@click="setWorkbenchDateMode('single')"
|
|
|
|
|
|
>
|
|
|
|
|
|
单日
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:class="{ active: workbenchDateMode === 'range' }"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@click="setWorkbenchDateMode('range')"
|
|
|
|
|
|
>
|
|
|
|
|
|
范围
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
|
|
|
|
|
<span>业务日期</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="workbenchSingleDate"
|
|
|
|
|
|
type="date"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@change="handleWorkbenchDateInputChange('range-start')"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="workbench-ai-date-field">
|
|
|
|
|
|
<span>结束日期</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="workbenchRangeEndDate"
|
|
|
|
|
|
type="date"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@change="handleWorkbenchDateInputChange('range-end')"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="workbench-ai-date-actions">
|
2026-06-20 14:42:04 +08:00
|
|
|
|
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
|
2026-06-18 22:12:24 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="primary"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@click="applyWorkbenchDateSelection"
|
|
|
|
|
|
>
|
|
|
|
|
|
完成
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="workbench-ai-icon-btn"
|
|
|
|
|
|
title="上传附件"
|
|
|
|
|
|
aria-label="上传附件"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@click="triggerAiModeFileUpload"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="mdi mdi-paperclip"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="workbench-ai-icon-btn"
|
|
|
|
|
|
title="语音输入"
|
|
|
|
|
|
aria-label="语音输入"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
@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"
|
2026-06-20 14:42:04 +08:00
|
|
|
|
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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>
|
2026-06-20 14:42:04 +08:00
|
|
|
|
|
|
|
|
|
|
<Transition name="workbench-ai-confirm-fade">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="applicationSubmitConfirmOpen"
|
|
|
|
|
|
class="workbench-ai-confirm-mask"
|
|
|
|
|
|
role="presentation"
|
|
|
|
|
|
@click.self="cancelInlineApplicationSubmitConfirm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="workbench-ai-confirm-dialog"
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-labelledby="workbench-ai-submit-confirm-title"
|
|
|
|
|
|
>
|
|
|
|
|
|
<h3 id="workbench-ai-submit-confirm-title">确认直接提交申请?</h3>
|
|
|
|
|
|
<p>确认后系统会先查询你名下相同日期的申请单;若发现重复或重叠日期,会停止提交并列出已有单据供你查看。</p>
|
|
|
|
|
|
<p>若核查通过,申请单会直接进入审批流程。</p>
|
|
|
|
|
|
<div class="workbench-ai-confirm-actions">
|
|
|
|
|
|
<button type="button" class="ghost" @click="cancelInlineApplicationSubmitConfirm">取消</button>
|
|
|
|
|
|
<button type="button" class="primary" :disabled="sending" @click="confirmInlineApplicationSubmit">确认直接提交</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
2026-06-18 22:12:24 +08:00
|
|
|
|
</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'
|
2026-06-20 10:17:37 +08:00
|
|
|
|
import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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 {
|
2026-06-20 10:17:37 +08:00
|
|
|
|
buildApplicationPreviewFooterMessage,
|
|
|
|
|
|
buildApplicationPreviewRows,
|
|
|
|
|
|
buildApplicationTemplatePreview,
|
|
|
|
|
|
buildLocalApplicationPreview,
|
|
|
|
|
|
buildLocalApplicationPreviewMessage,
|
|
|
|
|
|
normalizeApplicationPreview
|
|
|
|
|
|
} from '../../utils/expenseApplicationPreview.js'
|
2026-06-20 14:42:04 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildAiApplicationPrecheck,
|
|
|
|
|
|
buildAiApplicationPrecheckThinkingEvents,
|
|
|
|
|
|
buildAiApplicationSubmitConflictMessage,
|
|
|
|
|
|
isAiApplicationPrecheckBlocking
|
|
|
|
|
|
} from '../../utils/aiApplicationPrecheckModel.js'
|
2026-06-20 10:17:37 +08:00
|
|
|
|
import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
|
|
|
|
|
|
import {
|
|
|
|
|
|
buildAiDocumentQueryConditionSummary,
|
|
|
|
|
|
buildAiDocumentQueryMessage,
|
|
|
|
|
|
filterAiDocumentQueryRecords,
|
|
|
|
|
|
resolveAiDocumentQueryIntent
|
|
|
|
|
|
} from '../../utils/aiDocumentQueryModel.js'
|
2026-06-18 22:12:24 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildRequiredApplicationActions,
|
|
|
|
|
|
buildRequiredApplicationMissingText,
|
|
|
|
|
|
buildRequiredApplicationSelectionText,
|
|
|
|
|
|
filterRequiredApplicationCandidates
|
|
|
|
|
|
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
2026-06-20 10:17:37 +08:00
|
|
|
|
import {
|
|
|
|
|
|
calculateTravelReimbursement,
|
|
|
|
|
|
extractExpenseClaimItems,
|
|
|
|
|
|
fetchApprovalExpenseClaims,
|
|
|
|
|
|
fetchExpenseClaims
|
|
|
|
|
|
} from '../../services/reimbursements.js'
|
2026-06-20 14:42:04 +08:00
|
|
|
|
import {
|
|
|
|
|
|
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
|
|
|
|
|
AI_APPLICATION_ACTION_SUBMIT,
|
|
|
|
|
|
runAiApplicationPreviewAction
|
|
|
|
|
|
} from '../../services/aiApplicationPreviewActions.js'
|
2026-06-18 22:12:24 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document'])
|
|
|
|
|
|
const AI_DOCUMENT_QUERY_STEP_DELAY_MS = 320
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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)
|
2026-06-20 14:42:04 +08:00
|
|
|
|
const applicationSubmitConfirmOpen = ref(false)
|
|
|
|
|
|
const applicationSubmitConfirmContext = ref(null)
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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
|
2026-06-20 10:17:37 +08:00
|
|
|
|
const AI_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
2026-06-20 14:42:04 +08:00
|
|
|
|
const AI_APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
2026-06-20 10:17:37 +08:00
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
applicationPreviewEditor,
|
|
|
|
|
|
resolveApplicationPreviewEditorControl,
|
|
|
|
|
|
resolveApplicationPreviewEditorOptions,
|
|
|
|
|
|
refreshApplicationPreviewEstimate,
|
|
|
|
|
|
isApplicationPreviewEditing,
|
|
|
|
|
|
openApplicationPreviewEditor,
|
|
|
|
|
|
commitApplicationPreviewEditor,
|
|
|
|
|
|
cancelApplicationPreviewEditor,
|
|
|
|
|
|
handleApplicationPreviewEditorKeydown
|
|
|
|
|
|
} = useApplicationPreviewEditor({
|
|
|
|
|
|
persistSessionState: () => persistCurrentConversation(),
|
|
|
|
|
|
toast,
|
|
|
|
|
|
calculateTravelReimbursement,
|
|
|
|
|
|
currentUser
|
|
|
|
|
|
})
|
2026-06-18 22:12:24 +08:00
|
|
|
|
|
|
|
|
|
|
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}`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
const applicationPreviewEstimatePending = computed(() => (
|
|
|
|
|
|
conversationMessages.value.some((message) => isApplicationPreviewEstimatePending(message))
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value)
|
|
|
|
|
|
|
2026-06-18 22:12:24 +08:00
|
|
|
|
const canSubmitAiModePrompt = computed(() => (
|
2026-06-20 14:42:04 +08:00
|
|
|
|
!isAiModeInputLocked.value && (
|
|
|
|
|
|
Boolean(assistantDraft.value.trim())
|
|
|
|
|
|
|| selectedFiles.value.length > 0
|
|
|
|
|
|
|| Boolean(workbenchDateTagLabel.value)
|
|
|
|
|
|
)
|
2026-06-18 22:12:24 +08:00
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
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 : [],
|
2026-06-20 10:17:37 +08:00
|
|
|
|
applicationPreview: options.applicationPreview || null,
|
2026-06-20 14:42:04 +08:00
|
|
|
|
draftPayload: options.draftPayload || null,
|
2026-06-20 10:17:37 +08:00
|
|
|
|
text: options.text || normalizedContent,
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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,
|
2026-06-20 10:17:37 +08:00
|
|
|
|
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
|
|
|
|
|
applicationPreview: message.applicationPreview || null,
|
2026-06-20 14:42:04 +08:00
|
|
|
|
draftPayload: message.draftPayload || null,
|
2026-06-20 10:17:37 +08:00
|
|
|
|
text: message.text || message.content || ''
|
2026-06-18 22:12:24 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function serializeRuntimeMessage(message = {}) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: message.id,
|
|
|
|
|
|
role: message.role,
|
|
|
|
|
|
content: message.content,
|
2026-06-20 10:17:37 +08:00
|
|
|
|
text: message.text || message.content || '',
|
2026-06-18 22:12:24 +08:00
|
|
|
|
feedback: message.feedback || '',
|
|
|
|
|
|
stewardPlan: message.stewardPlan || null,
|
2026-06-20 10:17:37 +08:00
|
|
|
|
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
2026-06-20 14:42:04 +08:00
|
|
|
|
applicationPreview: message.applicationPreview || null,
|
|
|
|
|
|
draftPayload: message.draftPayload || null
|
2026-06-18 22:12:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-20 14:42:04 +08:00
|
|
|
|
applicationSubmitConfirmOpen.value = false
|
|
|
|
|
|
applicationSubmitConfirmContext.value = null
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
function renderInlineConversationHtml(content) {
|
|
|
|
|
|
return renderAiConversationHtml(content)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
|
|
|
|
|
|
const fields = normalizeApplicationPreview(applicationPreview).fields || {}
|
|
|
|
|
|
return [
|
|
|
|
|
|
fields.transportPolicy,
|
|
|
|
|
|
fields.policyEstimate,
|
|
|
|
|
|
fields.transportEstimatedAmount,
|
|
|
|
|
|
fields.amount
|
|
|
|
|
|
].some((value) => /正在|查询中/.test(String(value || '')))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isApplicationPreviewEstimatePending(message = {}) {
|
|
|
|
|
|
return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
|
|
|
|
|
|
return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function canShowInlineSuggestedActions(message = {}) {
|
|
|
|
|
|
return Boolean(message?.suggestedActions?.length) && !isApplicationPreviewEstimatePending(message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isInlineSuggestedActionDisabled(action = {}, message = {}) {
|
|
|
|
|
|
const actionType = String(action?.action_type || '').trim()
|
|
|
|
|
|
return (
|
2026-06-20 21:44:16 +08:00
|
|
|
|
Boolean(action?.disabled) ||
|
|
|
|
|
|
(
|
|
|
|
|
|
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
|
|
|
|
|
|
isApplicationPreviewEstimatePending(message)
|
|
|
|
|
|
)
|
2026-06-20 14:42:04 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
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
|
2026-06-20 14:42:04 +08:00
|
|
|
|
message.suggestedActions = buildInlineApplicationPreviewSuggestedActions(message.applicationPreview, message.draftPayload)
|
2026-06-20 10:17:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function commitInlineApplicationPreviewEditor(message) {
|
2026-06-20 14:42:04 +08:00
|
|
|
|
const shouldLockForEstimate = shouldRefreshInlineApplicationPreviewEstimate(applicationPreviewEditor.value.fieldKey)
|
|
|
|
|
|
if (shouldLockForEstimate) {
|
|
|
|
|
|
message.suggestedActions = []
|
|
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
}
|
2026-06-20 10:17:37 +08:00
|
|
|
|
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 || {})
|
2026-06-20 14:42:04 +08:00
|
|
|
|
if (isApplicationPreviewEstimatePending(message)) {
|
|
|
|
|
|
return '费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。'
|
|
|
|
|
|
}
|
2026-06-20 10:17:37 +08:00
|
|
|
|
if (normalized.validationIssues?.length || normalized.missingFields?.length) {
|
|
|
|
|
|
return buildApplicationPreviewFooterMessage(normalized)
|
|
|
|
|
|
}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
|
|
|
|
|
|
if (isApplicationPreviewEstimatePendingPreview(applicationPreview)) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalized = normalizeApplicationPreview(applicationPreview)
|
|
|
|
|
|
const actions = [{
|
|
|
|
|
|
label: '保存草稿',
|
|
|
|
|
|
description: '先保存当前申请表,后续可以继续补充或提交。',
|
|
|
|
|
|
icon: 'mdi mdi-content-save-outline',
|
|
|
|
|
|
action_type: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
|
|
|
|
|
payload: { draftPayload }
|
|
|
|
|
|
}]
|
|
|
|
|
|
if (normalized.readyToSubmit) {
|
|
|
|
|
|
actions.push({
|
|
|
|
|
|
label: '直接提交',
|
|
|
|
|
|
description: '提交前先核查相同日期申请单,确认通过后进入审批流程。',
|
|
|
|
|
|
icon: 'mdi mdi-send-check-outline',
|
|
|
|
|
|
action_type: AI_APPLICATION_ACTION_SUBMIT,
|
|
|
|
|
|
payload: { draftPayload }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return actions
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveLatestApplicationPreviewMessage() {
|
|
|
|
|
|
return [...conversationMessages.value]
|
|
|
|
|
|
.reverse()
|
|
|
|
|
|
.find((message) => message.role === 'assistant' && message.applicationPreview)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveInlineApplicationPreviewActionFromText(text = '') {
|
|
|
|
|
|
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
|
|
|
|
|
|
return AI_APPLICATION_ACTION_SAVE_DRAFT
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
|
|
|
|
|
|
return AI_APPLICATION_ACTION_SUBMIT
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractInlineApplicationDraftPayload(payload = {}) {
|
|
|
|
|
|
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
|
|
|
|
|
return result.draft_payload && typeof result.draft_payload === 'object'
|
|
|
|
|
|
? result.draft_payload
|
|
|
|
|
|
: payload?.draft_payload && typeof payload.draft_payload === 'object'
|
|
|
|
|
|
? payload.draft_payload
|
|
|
|
|
|
: null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
|
|
|
|
|
|
const text = String(value || '')
|
|
|
|
|
|
.replace(/\s*\n+\s*/g, ' ')
|
|
|
|
|
|
.replace(/\|/g, '|')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
return text || fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInlineApplicationActionDetailHref(reference = '') {
|
2026-06-20 21:44:16 +08:00
|
|
|
|
const source = reference && typeof reference === 'object' ? reference : { reference }
|
|
|
|
|
|
const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
|
|
|
|
|
|
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
|
|
|
|
|
|
const fallback = String(source.reference || '').trim()
|
|
|
|
|
|
if (claimId || claimNo) {
|
|
|
|
|
|
const params = new URLSearchParams()
|
|
|
|
|
|
if (claimId) {
|
|
|
|
|
|
params.set('claim_id', claimId)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (claimNo) {
|
|
|
|
|
|
params.set('claim_no', claimNo)
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
|
2026-06-20 14:42:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
|
|
|
|
|
|
const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
|
|
|
|
|
|
return {
|
|
|
|
|
|
claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim(),
|
|
|
|
|
|
claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
|
|
|
|
|
|
statusLabel: String(source.status_label || source.statusLabel || source.status || '').trim(),
|
|
|
|
|
|
approvalStage: String(source.approval_stage || source.approvalStage || '').trim(),
|
|
|
|
|
|
documentTypeLabel: String(
|
|
|
|
|
|
source.document_type_label ||
|
|
|
|
|
|
source.documentTypeLabel ||
|
|
|
|
|
|
source.application_type_label ||
|
|
|
|
|
|
source.applicationTypeLabel ||
|
|
|
|
|
|
source.expense_type_label ||
|
|
|
|
|
|
source.expenseTypeLabel ||
|
|
|
|
|
|
''
|
|
|
|
|
|
).trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
|
|
|
|
|
|
const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
|
|
|
|
|
|
const reference = info.claimNo || info.claimId
|
2026-06-20 21:44:16 +08:00
|
|
|
|
const href = buildInlineApplicationActionDetailHref(info)
|
2026-06-20 14:42:04 +08:00
|
|
|
|
const actionText = href ? `[查看](${href})` : '-'
|
|
|
|
|
|
return [
|
|
|
|
|
|
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
|
|
|
|
|
'| --- | --- | --- | --- | --- |',
|
|
|
|
|
|
`| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(info.statusLabel || options.statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${actionText} |`
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) {
|
|
|
|
|
|
const draftPayload = extractInlineApplicationDraftPayload(payload) || {}
|
|
|
|
|
|
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
|
|
|
|
|
const approvalStage = String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim()
|
|
|
|
|
|
if (actionType === AI_APPLICATION_ACTION_SUBMIT) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'### 申请单据已生成,并已进入审批流程',
|
|
|
|
|
|
approvalStage ? `系统已推送到 **${approvalStage}**,当前节点:${approvalStage}。` : '系统已推送到审批流程,当前节点:审批中。',
|
|
|
|
|
|
buildInlineApplicationResultTable(draftPayload, {
|
|
|
|
|
|
statusLabel: '审批中',
|
|
|
|
|
|
stageLabel: approvalStage || '直属领导审批',
|
|
|
|
|
|
documentTypeLabel: '出差申请'
|
|
|
|
|
|
}),
|
|
|
|
|
|
'需要查看完整详情时,请点击列表最后一列的“查看”进入单据详情。'
|
|
|
|
|
|
].filter(Boolean).join('\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
|
|
|
'### 申请草稿已保存',
|
|
|
|
|
|
claimNo ? `系统已保存当前申请草稿,草稿单号:**${claimNo}**。` : '系统已保存当前申请草稿。',
|
|
|
|
|
|
buildInlineApplicationResultTable(draftPayload, {
|
|
|
|
|
|
statusLabel: '草稿',
|
|
|
|
|
|
stageLabel: '待提交',
|
|
|
|
|
|
documentTypeLabel: '出差申请'
|
|
|
|
|
|
}),
|
|
|
|
|
|
'后续请点击表格最后一列的“查看”进入详情页继续核对。'
|
|
|
|
|
|
].filter(Boolean).join('\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInlineApplicationDetailAction(draftPayload = {}) {
|
|
|
|
|
|
const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
|
|
|
|
|
|
if (!claimNo) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
return [{
|
|
|
|
|
|
label: '查看单据详情',
|
|
|
|
|
|
description: '打开刚生成的申请单详情。',
|
|
|
|
|
|
icon: 'mdi mdi-open-in-new',
|
|
|
|
|
|
action_type: 'open_application_detail',
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
claim_no: claimNo,
|
|
|
|
|
|
claim_id: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
|
|
|
|
|
|
document_type: 'application'
|
|
|
|
|
|
}
|
|
|
|
|
|
}]
|
2026-06-18 22:12:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-20 14:42:04 +08:00
|
|
|
|
'这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。'
|
2026-06-18 22:12:24 +08:00
|
|
|
|
].filter(Boolean).join('\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (flow?.flowId === 'travel_reimbursement') {
|
|
|
|
|
|
return [
|
|
|
|
|
|
contextText || baseText,
|
2026-06-20 14:42:04 +08:00
|
|
|
|
'这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。'
|
2026-06-18 22:12:24 +08:00
|
|
|
|
].filter(Boolean).join('\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
return baseText
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
|
|
|
|
|
|
if (flow.flowId === 'travel_application') {
|
|
|
|
|
|
return [{
|
|
|
|
|
|
label: '确认发起出差申请',
|
|
|
|
|
|
description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。',
|
|
|
|
|
|
icon: 'mdi mdi-file-plus-outline',
|
|
|
|
|
|
action_type: 'ai_application_start_inline',
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
expense_type: 'travel',
|
|
|
|
|
|
expense_type_label: '差旅费',
|
|
|
|
|
|
carry_text: prompt
|
|
|
|
|
|
}
|
|
|
|
|
|
}]
|
|
|
|
|
|
}
|
|
|
|
|
|
if (flow.flowId === 'travel_reimbursement') {
|
|
|
|
|
|
return [{
|
|
|
|
|
|
label: '确认关联已有申请单',
|
|
|
|
|
|
description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。',
|
|
|
|
|
|
icon: 'mdi mdi-link-variant',
|
|
|
|
|
|
action_type: 'steward_confirm_flow',
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
steward_confirm_flow: true,
|
|
|
|
|
|
flow_id: 'travel_reimbursement',
|
|
|
|
|
|
expense_type: 'travel',
|
|
|
|
|
|
expense_type_label: '差旅费',
|
|
|
|
|
|
carry_text: prompt
|
|
|
|
|
|
}
|
|
|
|
|
|
}]
|
|
|
|
|
|
}
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveInlineApplicationDraftIdentity(payload = {}) {
|
|
|
|
|
|
const source = payload && typeof payload === 'object' ? payload : {}
|
|
|
|
|
|
return {
|
|
|
|
|
|
claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
|
|
|
|
|
|
claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isSameInlineApplicationDraftClaim(claim = {}, draftPayload = {}) {
|
|
|
|
|
|
const draftIdentity = resolveInlineApplicationDraftIdentity(draftPayload)
|
|
|
|
|
|
if (!draftIdentity.claimId && !draftIdentity.claimNo) {
|
2026-06-18 22:12:24 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
const claimIdentity = resolveInlineApplicationDraftIdentity(claim)
|
|
|
|
|
|
return Boolean(
|
|
|
|
|
|
(draftIdentity.claimId && claimIdentity.claimId && draftIdentity.claimId === claimIdentity.claimId) ||
|
|
|
|
|
|
(draftIdentity.claimNo && claimIdentity.claimNo && draftIdentity.claimNo === claimIdentity.claimNo)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInlineApplicationSubmitPrecheckPayload(claimsPayload, draftPayload = {}) {
|
|
|
|
|
|
const items = extractExpenseClaimItems(claimsPayload)
|
|
|
|
|
|
.filter((claim) => !isSameInlineApplicationDraftClaim(claim, draftPayload))
|
|
|
|
|
|
return { items }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function completeInlineThinkingEvents(events = []) {
|
|
|
|
|
|
return events.map((event) => ({
|
|
|
|
|
|
...event,
|
|
|
|
|
|
status: event.status === 'failed' ? 'failed' : 'completed'
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInitialInlineApplicationSubmitThinkingEvents() {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
eventId: 'application-precheck-overlap',
|
|
|
|
|
|
title: '核查同时间段申请单',
|
|
|
|
|
|
content: '正在查询你名下可见申请单,检查是否存在相同或重叠日期。',
|
|
|
|
|
|
status: 'running'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
eventId: 'application-precheck-budget',
|
|
|
|
|
|
title: '评估预算与审批影响',
|
|
|
|
|
|
content: '等待单据重叠核查完成后,继续评估预算占用和审批影响。',
|
|
|
|
|
|
status: 'pending'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
eventId: 'application-submit',
|
|
|
|
|
|
title: '提交申请单据',
|
|
|
|
|
|
content: '等待提交前核查完成。',
|
|
|
|
|
|
status: 'pending'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInlineApplicationSubmitThinkingEvents(precheck = {}) {
|
|
|
|
|
|
const blocked = isAiApplicationPrecheckBlocking(precheck)
|
|
|
|
|
|
return buildAiApplicationPrecheckThinkingEvents(precheck).map((event) => {
|
|
|
|
|
|
if (event.eventId !== 'application-precheck-form') {
|
|
|
|
|
|
return event
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
eventId: 'application-submit',
|
|
|
|
|
|
title: blocked ? '暂停提交申请' : '提交申请单据',
|
|
|
|
|
|
content: blocked
|
|
|
|
|
|
? '发现相同或重叠日期已有申请单,已暂停本次提交。'
|
|
|
|
|
|
: '提交前核查通过,正在生成申请单据并推送审批流程。',
|
|
|
|
|
|
status: blocked ? 'completed' : 'running'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildFailedInlineApplicationSubmitThinkingEvents(error) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
eventId: 'application-precheck-overlap',
|
|
|
|
|
|
title: '核查同时间段申请单',
|
|
|
|
|
|
content: `查询已有申请单失败:${String(error?.message || error || '未知错误')}`,
|
|
|
|
|
|
status: 'failed'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
eventId: 'application-submit',
|
|
|
|
|
|
title: '暂停提交申请',
|
|
|
|
|
|
content: '因为未能完成提交前重复日期核查,系统没有提交本次申请。',
|
|
|
|
|
|
status: 'failed'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
|
|
|
|
|
|
applicationSubmitConfirmContext.value = {
|
|
|
|
|
|
messageId: String(targetMessage?.id || '').trim(),
|
|
|
|
|
|
draftPayload: targetMessage?.draftPayload || options.draftPayload || null,
|
|
|
|
|
|
userText: String(options.userText || '直接提交').trim() || '直接提交'
|
|
|
|
|
|
}
|
|
|
|
|
|
applicationSubmitConfirmOpen.value = true
|
|
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cancelInlineApplicationSubmitConfirm() {
|
|
|
|
|
|
applicationSubmitConfirmOpen.value = false
|
|
|
|
|
|
applicationSubmitConfirmContext.value = null
|
|
|
|
|
|
focusAiModeInput()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function confirmInlineApplicationSubmit() {
|
|
|
|
|
|
const context = applicationSubmitConfirmContext.value || {}
|
|
|
|
|
|
applicationSubmitConfirmOpen.value = false
|
|
|
|
|
|
applicationSubmitConfirmContext.value = null
|
|
|
|
|
|
const sourceMessage = conversationMessages.value.find((message) => message.id === context.messageId)
|
|
|
|
|
|
if (!sourceMessage?.applicationPreview) {
|
|
|
|
|
|
toast('当前申请表已变化,请重新点击直接提交。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
void executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, sourceMessage, {
|
|
|
|
|
|
confirmed: true,
|
|
|
|
|
|
skipUserMessage: false,
|
|
|
|
|
|
draftPayload: context.draftPayload || null,
|
|
|
|
|
|
userText: context.userText || '直接提交'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runInlineApplicationSubmitPrecheck(targetMessage, pendingMessage, normalizedPreview, options = {}) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
|
|
|
|
|
const precheck = buildAiApplicationPrecheck(normalizedPreview, {
|
|
|
|
|
|
claimsPayload: buildInlineApplicationSubmitPrecheckPayload(
|
|
|
|
|
|
claimsPayload,
|
|
|
|
|
|
targetMessage.draftPayload || options.draftPayload || null
|
|
|
|
|
|
),
|
|
|
|
|
|
currentUser: currentUser.value || {},
|
|
|
|
|
|
expenseType: 'travel'
|
|
|
|
|
|
})
|
|
|
|
|
|
const thinkingEvents = buildInlineApplicationSubmitThinkingEvents(precheck)
|
|
|
|
|
|
const blocked = isAiApplicationPrecheckBlocking(precheck)
|
|
|
|
|
|
|
|
|
|
|
|
if (blocked) {
|
|
|
|
|
|
replaceInlineMessage(
|
|
|
|
|
|
pendingMessage.id,
|
|
|
|
|
|
createInlineMessage('assistant', buildAiApplicationSubmitConflictMessage(normalizedPreview, precheck), {
|
|
|
|
|
|
id: pendingMessage.id,
|
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
|
streamStatus: 'completed',
|
|
|
|
|
|
thinkingEvents
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
|
|
|
|
|
|
updateInlineMessageContent(message, '提交前核查通过,正在提交申请并进入审批流程...')
|
|
|
|
|
|
message.stewardPlan = {
|
|
|
|
|
|
...(message.stewardPlan || {}),
|
|
|
|
|
|
streamStatus: 'streaming',
|
|
|
|
|
|
thinkingEvents
|
|
|
|
|
|
}
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
|
|
|
|
|
return true
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
replaceInlineMessage(
|
|
|
|
|
|
pendingMessage.id,
|
|
|
|
|
|
createInlineMessage('assistant', [
|
|
|
|
|
|
'### 提交前核查失败',
|
|
|
|
|
|
'系统未能完成相同日期申请单查询,所以本次申请没有提交。',
|
|
|
|
|
|
'请稍后重试;如果仍然失败,请先到单据中心核对是否已有同日期申请单。'
|
|
|
|
|
|
].join('\n\n'), {
|
|
|
|
|
|
id: pendingMessage.id,
|
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
|
streamStatus: 'failed',
|
|
|
|
|
|
thinkingEvents: buildFailedInlineApplicationSubmitThinkingEvents(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
toast('提交前核查失败,已暂停提交。')
|
|
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
|
|
|
|
|
|
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
|
|
|
|
|
|
if (!targetMessage?.applicationPreview) {
|
|
|
|
|
|
toast('当前没有可提交的申请表。')
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
|
|
|
|
|
|
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
|
|
|
|
|
|
const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim()
|
|
|
|
|
|
|
|
|
|
|
|
if (isSubmit && !normalizedPreview.readyToSubmit) {
|
|
|
|
|
|
if (!options.skipUserMessage) {
|
|
|
|
|
|
pushInlineApplicationActionUserMessage(userText)
|
|
|
|
|
|
}
|
|
|
|
|
|
const missingText = normalizedPreview.missingFields?.length
|
|
|
|
|
|
? `当前还缺少:${normalizedPreview.missingFields.join('、')}。`
|
|
|
|
|
|
: ''
|
|
|
|
|
|
const validationText = normalizedPreview.validationIssues?.length
|
|
|
|
|
|
? normalizedPreview.validationIssues.map((item) => item.message).join(';')
|
|
|
|
|
|
: ''
|
|
|
|
|
|
conversationMessages.value.push(createInlineMessage('assistant', [
|
|
|
|
|
|
'### 暂不能提交申请',
|
|
|
|
|
|
missingText || validationText || '当前申请表还未通过提交校验。',
|
|
|
|
|
|
'请先点击表格中的字段补充或修正,补齐后我会开放“直接提交”入口。'
|
|
|
|
|
|
].filter(Boolean).join('\n\n')))
|
|
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
scrollInlineConversationToBottom()
|
2026-06-18 22:12:24 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
|
|
|
|
|
|
if (isSubmit && !options.confirmed) {
|
|
|
|
|
|
requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!options.skipUserMessage) {
|
|
|
|
|
|
pushInlineApplicationActionUserMessage(userText)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sending.value = true
|
|
|
|
|
|
const pendingMessage = createInlineMessage(
|
|
|
|
|
|
'assistant',
|
|
|
|
|
|
isSubmit ? '正在提交前核查相同日期申请单...' : '正在保存申请草稿...',
|
|
|
|
|
|
{
|
|
|
|
|
|
pending: true,
|
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
|
streamStatus: 'streaming',
|
|
|
|
|
|
thinkingEvents: isSubmit
|
|
|
|
|
|
? buildInitialInlineApplicationSubmitThinkingEvents()
|
|
|
|
|
|
: [
|
|
|
|
|
|
{
|
|
|
|
|
|
eventId: 'application-save-draft',
|
|
|
|
|
|
title: '保存申请草稿',
|
|
|
|
|
|
content: '正在按当前申请表内容保存草稿。',
|
|
|
|
|
|
status: 'running'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
conversationMessages.value.push(pendingMessage)
|
|
|
|
|
|
scrollInlineConversationToBottom()
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (isSubmit) {
|
|
|
|
|
|
const precheckPassed = await runInlineApplicationSubmitPrecheck(
|
|
|
|
|
|
targetMessage,
|
|
|
|
|
|
pendingMessage,
|
|
|
|
|
|
normalizedPreview,
|
|
|
|
|
|
options
|
|
|
|
|
|
)
|
|
|
|
|
|
if (!precheckPassed) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const payload = await runAiApplicationPreviewAction({
|
|
|
|
|
|
actionType,
|
|
|
|
|
|
applicationPreview: normalizedPreview,
|
|
|
|
|
|
currentUser: currentUser.value || {},
|
|
|
|
|
|
conversationId: conversationId.value,
|
|
|
|
|
|
draftPayload: targetMessage.draftPayload || options.draftPayload || null
|
|
|
|
|
|
})
|
|
|
|
|
|
const draftPayload = extractInlineApplicationDraftPayload(payload)
|
|
|
|
|
|
if (draftPayload) {
|
|
|
|
|
|
targetMessage.draftPayload = draftPayload
|
|
|
|
|
|
}
|
|
|
|
|
|
targetMessage.suggestedActions = []
|
|
|
|
|
|
replaceInlineMessage(
|
|
|
|
|
|
pendingMessage.id,
|
|
|
|
|
|
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
|
|
|
|
|
|
id: pendingMessage.id,
|
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
|
streamStatus: 'completed',
|
|
|
|
|
|
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
|
|
|
|
|
},
|
|
|
|
|
|
suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : []
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
2026-06-18 22:12:24 +08:00
|
|
|
|
return true
|
2026-06-20 14:42:04 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
replaceInlineMessage(
|
|
|
|
|
|
pendingMessage.id,
|
|
|
|
|
|
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
|
|
|
|
|
|
id: pendingMessage.id,
|
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
|
streamStatus: 'failed',
|
|
|
|
|
|
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
|
|
|
|
|
...item,
|
|
|
|
|
|
status: 'failed'
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
toast(error?.message || (isSubmit ? '申请提交失败。' : '申请草稿保存失败。'))
|
|
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
return true
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
sending.value = false
|
|
|
|
|
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
2026-06-18 22:12:24 +08:00
|
|
|
|
}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleInlineApplicationPreviewTextAction(prompt) {
|
|
|
|
|
|
if (applicationPreviewEstimatePending.value) {
|
|
|
|
|
|
toast('请等待费用测算完成后再继续操作。')
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
|
|
|
|
|
|
if (!actionType || !resolveLatestApplicationPreviewMessage()) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
void executeInlineApplicationPreviewAction(actionType, null, { userText: prompt })
|
|
|
|
|
|
return true
|
2026-06-18 22:12:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
function parseAiApplicationDetailHref(href = '') {
|
|
|
|
|
|
const value = String(href || '').trim()
|
|
|
|
|
|
if (!value.startsWith(AI_APPLICATION_DETAIL_HREF_PREFIX)) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
const encodedReference = value.slice(AI_APPLICATION_DETAIL_HREF_PREFIX.length)
|
|
|
|
|
|
if (!encodedReference) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
2026-06-20 21:44:16 +08:00
|
|
|
|
let reference = ''
|
2026-06-20 14:42:04 +08:00
|
|
|
|
try {
|
2026-06-20 21:44:16 +08:00
|
|
|
|
reference = decodeURIComponent(encodedReference).trim()
|
2026-06-20 14:42:04 +08:00
|
|
|
|
} catch {
|
2026-06-20 21:44:16 +08:00
|
|
|
|
reference = encodedReference.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!reference) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
const params = new URLSearchParams(reference)
|
|
|
|
|
|
const claimId = String(params.get('claim_id') || '').trim()
|
|
|
|
|
|
const claimNo = String(params.get('claim_no') || '').trim()
|
|
|
|
|
|
if (claimId || claimNo) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
reference: claimNo || claimId,
|
|
|
|
|
|
claimId,
|
|
|
|
|
|
claimNo
|
|
|
|
|
|
}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
}
|
2026-06-20 21:44:16 +08:00
|
|
|
|
return { reference }
|
2026-06-20 14:42:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
function buildAiDocumentDetailRequest(detailReference = {}) {
|
|
|
|
|
|
const reference = String(detailReference.reference || '').trim()
|
2026-06-20 21:44:16 +08:00
|
|
|
|
const claimId = String(detailReference.claimId || detailReference.claim_id || '').trim()
|
|
|
|
|
|
const claimNo = String(detailReference.claimNo || detailReference.claim_no || '').trim()
|
|
|
|
|
|
const lookupReference = claimId || reference
|
|
|
|
|
|
const displayReference = claimNo || reference
|
|
|
|
|
|
const isApplication = /^APP?-/i.test(displayReference) || Boolean(claimId || claimNo)
|
2026-06-20 10:17:37 +08:00
|
|
|
|
return {
|
2026-06-20 21:44:16 +08:00
|
|
|
|
id: lookupReference,
|
|
|
|
|
|
claimId: claimId || reference,
|
|
|
|
|
|
claimNo: claimNo || reference,
|
|
|
|
|
|
documentNo: displayReference,
|
2026-06-20 10:17:37 +08:00
|
|
|
|
documentType: isApplication ? 'application' : 'reimbursement',
|
|
|
|
|
|
documentTypeCode: isApplication ? 'application' : 'reimbursement',
|
2026-06-20 14:42:04 +08:00
|
|
|
|
detailLookupOnly: true,
|
2026-06-20 10:17:37 +08:00
|
|
|
|
source: 'workbench',
|
|
|
|
|
|
returnTo: 'workbench'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleAiAnswerMarkdownClick(event) {
|
|
|
|
|
|
const target = event?.target
|
2026-06-20 14:42:04 +08:00
|
|
|
|
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
2026-06-20 10:17:37 +08:00
|
|
|
|
if (!link) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
const href = link.getAttribute('href')
|
|
|
|
|
|
const detailReference = parseAiDocumentDetailHref(href) || parseAiApplicationDetailHref(href)
|
2026-06-20 10:17:37 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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 {
|
2026-06-20 10:17:37 +08:00
|
|
|
|
if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
|
|
|
|
|
|
shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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'
|
|
|
|
|
|
},
|
2026-06-20 14:42:04 +08:00
|
|
|
|
suggestedActions: requiredApplicationContinuationFlow
|
|
|
|
|
|
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
|
|
|
|
|
|
: buildStewardSuggestedActions(plan)
|
2026-06-18 22:12:24 +08:00
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
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 = []) {
|
2026-06-20 14:42:04 +08:00
|
|
|
|
if (isAiModeInputLocked.value) {
|
|
|
|
|
|
toast('请等待费用测算完成后再继续操作。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-18 22:12:24 +08:00
|
|
|
|
const cleanPrompt = buildInlinePromptText(prompt, files)
|
|
|
|
|
|
if (!cleanPrompt || sending.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
|
|
|
|
|
|
advanceAiExpenseDraft(cleanPrompt, files)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
if (handleInlineApplicationPreviewTextAction(cleanPrompt)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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' }, [])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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 : {}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
if ([AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType)) {
|
|
|
|
|
|
if (isInlineSuggestedActionDisabled(action, sourceMessage)) {
|
|
|
|
|
|
toast('请等待费用测算完成后再继续操作。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
void executeInlineApplicationPreviewAction(actionType, sourceMessage, {
|
|
|
|
|
|
userText: action.label,
|
|
|
|
|
|
draftPayload: actionPayload.draftPayload || actionPayload.draft_payload || null
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (actionType === 'open_application_detail') {
|
|
|
|
|
|
const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
|
|
|
|
|
|
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
|
2026-06-20 21:44:16 +08:00
|
|
|
|
emit('open-document', buildAiDocumentDetailRequest({
|
|
|
|
|
|
reference: claimNo || claimId,
|
|
|
|
|
|
claimId,
|
|
|
|
|
|
claimNo
|
|
|
|
|
|
}))
|
2026-06-20 14:42:04 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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() || '差旅费'
|
2026-06-20 10:17:37 +08:00
|
|
|
|
void startAiApplicationPreview(
|
|
|
|
|
|
expenseType,
|
|
|
|
|
|
expenseTypeLabel,
|
|
|
|
|
|
actionPayload.carry_text || resolveLatestInlineUserPrompt()
|
|
|
|
|
|
)
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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()
|
2026-06-20 10:17:37 +08:00
|
|
|
|
void startAiApplicationPreview(
|
|
|
|
|
|
expenseType,
|
|
|
|
|
|
expenseTypeLabel,
|
|
|
|
|
|
action?.payload?.carry_text || resolveLatestInlineUserPrompt()
|
|
|
|
|
|
)
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
function pushInlineApplicationActionUserMessage(text) {
|
|
|
|
|
|
pushInlineUserMessage(text)
|
|
|
|
|
|
assistantDraft.value = ''
|
|
|
|
|
|
removeWorkbenchDateTag()
|
|
|
|
|
|
closeWorkbenchDatePicker()
|
|
|
|
|
|
clearAiModeFiles()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
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 || '费用申请'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 22:12:24 +08:00
|
|
|
|
// 选定报销类型后,在当前对话页内启动逐项收集流程;
|
|
|
|
|
|
// 差旅/招待需先查申请单,其余类型直接进入字段填写。
|
|
|
|
|
|
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: [{
|
2026-06-20 14:42:04 +08:00
|
|
|
|
label: '确认发起出差申请',
|
|
|
|
|
|
description: '生成完整申请表,并预填已识别的时间、地点和事由',
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
// 进入申请核对表:复用原有申请预览模型,一次性展示可编辑表格和自动测算结果。
|
|
|
|
|
|
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
|
2026-06-18 22:12:24 +08:00
|
|
|
|
assistantDraft.value = ''
|
2026-06-20 10:17:37 +08:00
|
|
|
|
removeWorkbenchDateTag()
|
|
|
|
|
|
closeWorkbenchDatePicker()
|
2026-06-18 22:12:24 +08:00
|
|
|
|
clearAiModeFiles()
|
2026-06-20 10:17:37 +08:00
|
|
|
|
if (options.pushUserMessage !== false) {
|
|
|
|
|
|
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
|
2026-06-18 22:12:24 +08:00
|
|
|
|
}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
|
|
|
|
|
|
const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
|
|
|
|
|
|
pending: true,
|
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
|
streamStatus: 'streaming',
|
|
|
|
|
|
thinkingEvents: [
|
|
|
|
|
|
{
|
|
|
|
|
|
eventId: 'application-preview-build',
|
|
|
|
|
|
title: '整理申请表字段',
|
|
|
|
|
|
content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。',
|
|
|
|
|
|
status: 'running'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
eventId: 'application-preview-estimate',
|
|
|
|
|
|
title: '同步费用测算',
|
|
|
|
|
|
content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
|
|
|
|
|
|
status: 'pending'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
conversationMessages.value.push(pendingMessage)
|
2026-06-18 22:12:24 +08:00
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
scrollInlineConversationToBottom()
|
2026-06-20 14:42:04 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const preview = await refreshApplicationPreviewEstimate(
|
|
|
|
|
|
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText)
|
|
|
|
|
|
)
|
|
|
|
|
|
const content = buildLocalApplicationPreviewMessage(preview)
|
|
|
|
|
|
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, {
|
|
|
|
|
|
id: pendingMessage.id,
|
|
|
|
|
|
applicationPreview: preview,
|
|
|
|
|
|
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
|
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
|
streamStatus: 'completed',
|
|
|
|
|
|
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
|
|
|
|
|
},
|
|
|
|
|
|
text: content
|
|
|
|
|
|
}))
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
|
|
|
|
|
id: pendingMessage.id,
|
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
|
streamStatus: 'failed',
|
|
|
|
|
|
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
|
|
|
|
|
...item,
|
|
|
|
|
|
status: 'failed'
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
toast(error?.message || '申请核对表生成失败。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
persistCurrentConversation()
|
|
|
|
|
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
|
|
|
|
|
}
|
2026-06-18 22:12:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-06-20 14:42:04 +08:00
|
|
|
|
if (isAiModeInputLocked.value) {
|
|
|
|
|
|
toast('请等待费用测算完成后再继续操作。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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() {
|
2026-06-20 14:42:04 +08:00
|
|
|
|
if (isAiModeInputLocked.value) {
|
|
|
|
|
|
toast('请等待费用测算完成后再继续操作。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-06-18 22:12:24 +08:00
|
|
|
|
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>
|