feat: 小财管家意图规划与报销提交编排增强

- 完善管家意图识别、模型计划构建与规划器调度
- 重构差旅报销提交编排器与管家计划流程前端交互
- 优化报销消息项样式与文档中心视图
- 新增小财管家与附件上传风险前置复核设计文档
- 补充管家规划器与文档中心测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 14:25:14 +08:00
parent 1cbf3fee44
commit f60cebadb8
19 changed files with 2337 additions and 196 deletions

View File

@@ -29,6 +29,10 @@
gap: 8px;
}
.message-row.has-steward-plan .message-stack {
width: min(100%, 760px);
}
.message-row.user .message-stack {
order: 1;
justify-items: end;
@@ -64,8 +68,15 @@
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14);
}
.message-row.has-steward-plan .message-bubble {
width: 100%;
max-width: none;
box-sizing: border-box;
}
.steward-intent-bubble {
width: min(100%, 680px);
width: 100%;
box-sizing: border-box;
border: 1px solid #c9ddea;
border-radius: 4px;
background: #eef6fb;
@@ -144,6 +155,17 @@
line-height: 1.55;
}
.steward-intent-event-list li span.typing::after {
content: "";
width: 5px;
height: 12px;
display: inline-block;
margin-left: 3px;
vertical-align: -2px;
background: #3a7ca5;
animation: steward-plan-caret 900ms steps(1, end) infinite;
}
.steward-intent-empty {
margin: 0;
padding: 0 12px 12px;
@@ -231,6 +253,229 @@
font-weight: 850;
}
.steward-plan-markdown {
display: block;
}
.steward-plan-markdown :deep(h2) {
margin: 0 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid #dbe4ee;
color: #0f172a;
font-size: 15px;
font-weight: 860;
line-height: 1.42;
}
.steward-plan-markdown :deep(hr) {
margin: 10px 0 14px;
border: 0;
border-top: 1px solid #e2e8f0;
}
.steward-plan-markdown :deep(h3) {
margin: 16px 0 8px;
padding-left: 8px;
border-left: 3px solid var(--theme-primary, #3a7ca5);
color: #183247;
font-size: 13px;
font-weight: 840;
line-height: 1.45;
}
.steward-plan-markdown :deep(h3:first-child) {
margin-top: 0;
}
.steward-plan-markdown :deep(p) {
margin: 0 0 10px;
color: #334155;
line-height: 1.72;
}
.steward-plan-markdown :deep(ul) {
margin: 0 0 12px;
padding-left: 18px;
display: grid;
gap: 7px;
}
.steward-plan-markdown :deep(li) {
margin: 0;
color: #334155;
line-height: 1.65;
}
.steward-plan-typing::after {
content: "";
width: 6px;
height: 15px;
display: inline-block;
margin-left: 3px;
vertical-align: -2px;
background: var(--theme-primary, #3a7ca5);
animation: steward-plan-caret 900ms steps(1, end) infinite;
}
@keyframes steward-plan-caret {
0%,
45% {
opacity: 1;
}
46%,
100% {
opacity: 0;
}
}
.steward-plan-block {
margin-top: 14px;
display: grid;
gap: 12px;
}
.steward-task-list,
.steward-attachment-list {
display: grid;
gap: 10px;
}
.steward-task-card,
.steward-attachment-card {
padding: 12px;
border: 1px solid #dbe7f2;
border-radius: 4px;
background: #f8fbfe;
}
.steward-task-header,
.steward-attachment-card header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.steward-task-type,
.steward-attachment-card header span {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border: 1px solid #c7deef;
border-radius: 4px;
background: #eef7fc;
color: #24618a;
font-size: 12px;
font-weight: 780;
}
.steward-task-agent,
.steward-attachment-card header small {
color: #64748b;
font-size: 12px;
font-weight: 720;
}
.steward-task-body {
display: grid;
gap: 5px;
}
.steward-task-title {
display: block;
color: #172a3a;
font-size: 14px;
font-weight: 850;
line-height: 1.45;
}
.steward-task-summary,
.steward-attachment-card p {
margin: 0;
color: #5c7185;
font-size: 12px;
line-height: 1.62;
}
.steward-task-meta,
.steward-attachment-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
}
.steward-task-meta span,
.steward-attachment-chip {
border: 1px solid #d5e2ee;
border-radius: 4px;
background: #ffffff;
color: #49677f;
font-size: 12px;
line-height: 1.4;
padding: 3px 7px;
}
.steward-task-missing {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #d5e2ee;
display: grid;
gap: 8px;
}
.steward-task-missing-label {
color: #425c72;
font-size: 12px;
font-weight: 820;
}
.steward-task-missing-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.steward-task-missing-list li {
display: grid;
grid-template-columns: minmax(86px, 120px) minmax(0, 1fr);
align-items: start;
gap: 8px;
padding: 7px 8px;
border: 1px solid #e1e8f0;
border-radius: 4px;
background: #ffffff;
}
.steward-task-missing-list strong {
color: #1f3448;
font-size: 12px;
font-weight: 820;
line-height: 1.45;
}
.steward-task-missing-list small {
color: #6b7f92;
font-size: 12px;
line-height: 1.45;
}
.steward-attachment-chip.include {
border-color: #c7deef;
background: #eef7fc;
color: #24618a;
}
.steward-attachment-chip.exclude {
border-color: #ecd6c4;
background: #fff8f2;
color: #8a5a24;
}
.message-answer-markdown :deep(.markdown-table-wrap) {
width: 100%;
max-width: 100%;
@@ -811,4 +1056,9 @@
max-width: 100%;
border-radius: 12px;
}
.steward-task-missing-list li {
grid-template-columns: 1fr;
gap: 3px;
}
}

View File

@@ -141,6 +141,42 @@
padding: 3px 7px;
}
.steward-task-missing {
margin-top: 9px;
padding-top: 9px;
border-top: 1px dashed #d5e2ee;
display: grid;
gap: 6px;
}
.steward-task-missing-label {
color: #5d7489;
font-size: 12px;
font-weight: 780;
}
.steward-task-missing-chip {
display: grid;
gap: 2px;
padding: 7px 8px;
border: 1px solid #e1e8f0;
border-radius: 4px;
background: #ffffff;
}
.steward-task-missing-chip strong {
color: #1f3448;
font-size: 12px;
font-weight: 820;
line-height: 1.35;
}
.steward-task-missing-chip small {
color: #6b7f92;
font-size: 11px;
line-height: 1.45;
}
.steward-attachment-chip.include {
border-color: #c7deef;
background: #eef7fc;

View File

@@ -1,7 +1,7 @@
<template>
<article
class="message-row"
:class="message.role"
:class="[message.role, { 'has-steward-plan': message.stewardPlan }]"
>
<span class="message-avatar">
<img
@@ -31,7 +31,7 @@
:key="`${message.id}-${event.eventId}`"
>
<strong>{{ event.title }}</strong>
<span>{{ event.content }}</span>
<span :class="{ 'typing': event.status === 'running' }">{{ event.content }}</span>
</li>
</ol>
<p v-else class="steward-intent-empty">正在建立任务上下文...</p>
@@ -62,7 +62,14 @@
<div
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
:class="[
'message-answer-content',
'message-answer-markdown',
{
'steward-plan-markdown': message.stewardPlan,
'steward-plan-typing': message.stewardPlan?.streamStatus === 'typing'
}
]"
v-html="ui.renderMarkdown(message.text)"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
@@ -73,7 +80,7 @@
/>
<div
v-if="message.role === 'assistant' && message.stewardPlan && message.stewardPlan.streamStatus !== 'streaming'"
v-if="message.role === 'assistant' && message.stewardPlan && message.stewardPlan.streamStatus !== 'streaming' && !message.stewardPlan.initialSummaryOnly"
class="steward-plan-block"
role="group"
aria-label="小财管家任务计划"
@@ -84,16 +91,32 @@
:key="`${message.id}-${task.taskId}`"
class="steward-task-card"
>
<header>
<span>{{ task.taskTypeLabel }}</span>
<small>{{ task.assignedAgentLabel }}</small>
<header class="steward-task-header">
<span class="steward-task-type">{{ task.taskTypeLabel }}</span>
<span class="steward-task-agent">{{ task.assignedAgentLabel }}</span>
</header>
<strong>{{ task.title }}</strong>
<p>{{ task.summary }}</p>
<div class="steward-task-body">
<strong class="steward-task-title">{{ task.title }}</strong>
<p class="steward-task-summary">{{ task.summary }}</p>
</div>
<div class="steward-task-meta">
<span>置信度 {{ Math.round((task.confidence || 0) * 100) }}%</span>
<span v-if="task.missingFields?.length">待补充 {{ task.missingFields.join('') }}</span>
<span v-else>字段已齐备</span>
<span v-if="!ui.resolveStewardMissingFieldItems(task).length">信息已基本齐备</span>
</div>
<div
v-if="ui.resolveStewardMissingFieldItems(task).length"
class="steward-task-missing"
>
<span class="steward-task-missing-label">还需要补充</span>
<ul class="steward-task-missing-list">
<li
v-for="field in ui.resolveStewardMissingFieldItems(task)"
:key="`${task.taskId}-missing-${field.key}`"
>
<strong>{{ field.label }}</strong>
<small v-if="field.hint">{{ field.hint }}</small>
</li>
</ul>
</div>
</article>
</div>

View File

@@ -11,12 +11,28 @@ export function fetchStewardPlan(payload, options = {}) {
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
const {
timeoutMs = 0,
idleTimeoutMs = 90000,
timeoutMessage = '小财管家任务规划超时,请稍后重试。'
} = options
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
const timeoutId = controller && Number(timeoutMs) > 0
? globalThis.setTimeout(() => controller.abort(), Number(timeoutMs))
: 0
let timeoutId = 0
const armAbortTimer = (delayMs) => {
if (!controller) return
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
timeoutId = 0
}
if (Number(delayMs) > 0) {
timeoutId = globalThis.setTimeout(() => controller.abort(), Number(delayMs))
}
}
const clearAbortTimer = () => {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
timeoutId = 0
}
}
armAbortTimer(timeoutMs)
let response
try {
@@ -29,9 +45,7 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
signal: controller?.signal
})
} catch (error) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
clearAbortTimer()
if (error?.name === 'AbortError') {
throw new Error(timeoutMessage)
}
@@ -39,17 +53,13 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
}
if (!response.ok) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
clearAbortTimer()
throw new Error(await resolveStreamError(response))
}
if (!response.body?.getReader) {
const text = await response.text()
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
clearAbortTimer()
return consumeNdjsonText(text, handlers)
}
@@ -59,11 +69,13 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
let finalPlan = null
try {
armAbortTimer(idleTimeoutMs)
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
armAbortTimer(idleTimeoutMs)
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
@@ -87,9 +99,7 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
}
throw error
} finally {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
clearAbortTimer()
}
if (!finalPlan) {

View File

@@ -562,9 +562,17 @@ const filteredRows = computed(() => {
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const pageSummary = computed(() => `${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
function resolveLiveDocumentRow(row) {
return {
...row,
isNewDocument: isNewDocument(row, viewedDocumentKeys.value)
}
}
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
return filteredRows.value.slice(start, start + pageSize.value).map(resolveLiveDocumentRow)
})
const documentLoadingSource = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length)

View File

@@ -16,6 +16,11 @@ import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSu
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
import {
buildStewardFieldItems,
formatStewardMissingFieldList,
formatStewardOntologyFields
} from './stewardPlanModel.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import {
buildOperationFeedbackPayload,
@@ -202,6 +207,10 @@ import {
userAvatar
} from './travelReimbursementConversationModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 14
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
@@ -1546,6 +1555,7 @@ export default {
replaceMessage,
scrollToBottom,
adjustComposerTextareaHeight,
executeStewardSuggestedAction: (message, action) => handleSuggestedAction(message, action),
submitting,
reviewActionBusy,
sessionSwitchBusy,
@@ -1695,6 +1705,30 @@ export default {
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText
await submitComposerInternal({
rawText: carryText,
userText: action.label || '确定',
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
? '小财管家正在调用申请助手生成申请单核对结果...'
: '小财管家正在调用报销助手整理报销核对结果...',
files: carryFiles,
skipScopeGuard: true,
skipStewardPlan: true,
skipUserMessage: confirmedByText,
sessionTypeOverride: targetSessionType,
stewardContinuation: {
planId: String(actionPayload.steward_plan_id || '').trim(),
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
? actionPayload.steward_remaining_tasks
: []
}
})
return
}
await switchSessionType(targetSessionType)
if (carryText) {
composerDraft.value = carryText
@@ -2161,6 +2195,195 @@ export default {
return String(request.claimId || request.claim_id || '').trim()
}
function buildStewardContinuationAfterAction(message, completedLabel = '当前动作已完成') {
const continuation = message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
if (!remainingTasks.length) {
return null
}
const nextTask = remainingTasks[0]
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const targetSessionType = nextTaskType === 'expense_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION
? '继续创建申请单'
: '继续填写报销单'
const restTasks = remainingTasks.slice(1)
return createMessage(
'assistant',
[
`**${completedLabel}。**`,
'',
'我会重新检查剩余任务队列。',
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}`,
'请回复“确定”,我再继续执行。'
].join('\n'),
[],
{
assistantName: '小财管家',
meta: ['小财管家', '等待用户确认'],
suggestedActions: [
{
label: nextLabel,
description: '确认后小财管家继续调用对应助手完成下一步。',
icon: targetSessionType === SESSION_TYPE_APPLICATION
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
auto_submit: true,
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
steward_remaining_tasks: restTasks
}
}
]
}
)
}
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
return {
planId: planId || `steward-followup-${Date.now()}`,
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function buildStewardFollowupThinkingEvents() {
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
return [
{
eventId: `${eventPrefix}-review`,
title: '复盘结果',
content: '当前动作已完成,小财管家正在检查剩余任务队列。'
},
{
eventId: `${eventPrefix}-next`,
title: '选择下一步',
content: '我会继续保持一步一步推进,先说明下一步,再等你确认后执行。'
}
]
}
function waitStewardFollowupTick(intervalMs) {
return new Promise((resolve) => {
window.setTimeout(resolve, intervalMs)
})
}
async function pushStewardContinuationMessage(finalMessage) {
if (!finalMessage) {
return
}
const finalText = String(finalMessage.text || '')
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const finalActions = Array.isArray(finalMessage.suggestedActions)
? finalMessage.suggestedActions
: []
finalMessage.text = ''
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
finalMessage.suggestedActions = []
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
messages.value.push(finalMessage)
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
for (const eventData of buildStewardFollowupThinkingEvents()) {
const event = {
eventId: eventData.eventId,
stage: 'steward_followup',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(eventData.content)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
event.content = eventData.content
event.status = 'completed'
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
}
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
const chars = Array.from(finalText)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
finalMessage.text = chars.slice(0, index + 1).join('')
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
finalMessage.text = finalText
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
finalMessage.suggestedActions = finalActions
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
}
function buildStewardContinuationCarryText(task, restTasks = []) {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
const missingFields = formatStewardMissingFieldList(task?.missing_fields || task?.missingFields || [], taskType)
const lines = [
taskType === 'expense_application'
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}`
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields ? `已识别信息:${fields}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
'请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
]
if (restTasks.length) {
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
restTasks.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
})
}
return lines.filter(Boolean).join('\n')
}
function resolveStewardMissingFieldItems(task) {
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) {
return task.missingFieldItems
}
const fields = task?.missingFields || task?.missing_fields || []
const taskType = String(task?.taskType || task?.task_type || '').trim()
return buildStewardFieldItems(fields, taskType)
}
async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
@@ -2185,6 +2408,8 @@ export default {
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
@@ -2229,6 +2454,10 @@ export default {
persistSessionState()
nextTick(scrollToBottom)
}
const stewardFollowup = buildStewardContinuationAfterAction(message, '申请单已完成')
if (stewardFollowup) {
await pushStewardContinuationMessage(stewardFollowup)
}
} finally {
reviewActionBusy.value = false
}
@@ -2449,7 +2678,7 @@ export default {
// submitting.value = true
// recognizeOcrFiles(files)
// submitting.value = false
if (isStewardSession.value && await submitStewardPlan(options)) {
if (isStewardSession.value && !options.skipStewardPlan && await submitStewardPlan(options)) {
return null
}
if (await handleGuidedComposerSubmit(options)) {
@@ -2570,6 +2799,7 @@ export default {
sessionSwitchBusy: sessionSwitchBusy.value,
applicationPreviewEditor: applicationPreviewEditor.value,
buildMessageBubbleClass,
resolveStewardMissingFieldItems,
buildReviewMainMessageText,
renderMarkdown,
handleAssistantMarkdownClick,

View File

@@ -13,7 +13,70 @@ const TASK_TYPE_LABELS = {
const AGENT_LABELS = {
application_assistant: '申请助手',
reimbursement_assistant: '报销助手'
application: '申请助手',
expense_application: '申请助手',
reimbursement_assistant: '报销助手',
reimbursement: '报销助手',
expense: '报销助手'
}
const FIELD_DISPLAY_CONFIG = {
expense_type: {
label: '费用类型',
hint: '例如差旅、交通、住宿、业务招待'
},
time_range: {
label: '发生时间',
hint: '申请时填出差起止日期,报销时填费用发生日期'
},
location: {
label: '地点',
hint: '出差城市或费用发生地点'
},
reason: {
label: '事由',
hint: '出差、报销或业务活动的具体原因'
},
amount: {
label: '金额',
hint: '申请时为预计金额,报销时为实际报销金额'
},
transport_mode: {
label: '出行方式',
hint: '例如高铁、飞机、自驾、出租车'
},
attachments: {
label: '附件/凭证',
hint: '发票、行程单、付款截图或其他证明材料'
},
customer_name: {
label: '客户或项目对象',
hint: '涉及的客户、单位或项目名称'
},
merchant_name: {
label: '商户/开票方',
hint: '发票或付款凭证上的商户名称'
},
department_name: {
label: '所属部门',
hint: '申请人或费用归属部门'
},
employee_name: {
label: '申请人',
hint: '发起申请或报销的员工姓名'
},
employee_no: {
label: '员工编号',
hint: '公司内部员工编号'
}
}
const FIELD_ALIASES = {
occurred_date: 'time_range',
business_time: 'time_range',
reason_value: 'reason',
transport_type: 'transport_mode',
application_transport_mode: 'transport_mode'
}
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
@@ -47,6 +110,7 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''),
summary: String(rawPlan.summary || ''),
visibleThinkingEventCount,
initialSummaryOnly: Boolean(rawPlan.initial_summary_only || rawPlan.initialSummaryOnly || options.initialSummaryOnly),
thinkingEvents: Array.isArray(rawPlan.thinking_events)
? rawPlan.thinking_events.map((item) => ({
eventId: String(item.event_id || item.eventId || ''),
@@ -57,22 +121,30 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
}))
: [],
tasks: Array.isArray(rawPlan.tasks)
? rawPlan.tasks.map((item) => ({
taskId: String(item.task_id || item.taskId || ''),
taskType: String(item.task_type || item.taskType || ''),
taskTypeLabel: TASK_TYPE_LABELS[String(item.task_type || item.taskType || '')] || '财务任务',
assignedAgent: String(item.assigned_agent || item.assignedAgent || ''),
assignedAgentLabel: AGENT_LABELS[String(item.assigned_agent || item.assignedAgent || '')] || '财务助手',
title: String(item.title || ''),
summary: String(item.summary || ''),
status: String(item.status || ''),
confidence: Number(item.confidence || 0),
ontologyFields: item.ontology_fields || item.ontologyFields || {},
missingFields: Array.isArray(item.missing_fields || item.missingFields)
? rawPlan.tasks.map((item) => {
const taskType = String(item.task_type || item.taskType || '')
const missingFields = Array.isArray(item.missing_fields || item.missingFields)
? item.missing_fields || item.missingFields
: [],
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
}))
: []
return {
taskId: String(item.task_id || item.taskId || ''),
taskType,
taskTypeLabel: TASK_TYPE_LABELS[taskType] || '财务任务',
assignedAgent: String(item.assigned_agent || item.assignedAgent || ''),
assignedAgentLabel:
AGENT_LABELS[String(item.assigned_agent || item.assignedAgent || '')] ||
AGENT_LABELS[taskType] ||
'小财管家',
title: String(item.title || ''),
summary: String(item.summary || ''),
status: String(item.status || ''),
confidence: Number(item.confidence || 0),
ontologyFields: item.ontology_fields || item.ontologyFields || {},
missingFields,
missingFieldItems: buildStewardFieldItems(missingFields, taskType),
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
}
})
: [],
attachmentGroups: Array.isArray(rawPlan.attachment_groups)
? rawPlan.attachment_groups.map((item) => ({
@@ -99,34 +171,67 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
export function buildStewardPlanMessageText(plan) {
const normalized = normalizeStewardPlan(plan)
const taskLines = normalized.tasks.map((task, index) =>
`${index + 1}. ${task.title || task.taskTypeLabel},交给${task.assignedAgentLabel}`
const nextContext = resolveNextActionContext(normalized)
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
const taskLines = orderedTasks.map((task, index) =>
`${index + 1}. **${buildTaskOrderVerb(index)}${buildTaskOrderTarget(task)}**\n - ${buildTaskOrderActionDescription(task)}`
)
return [
'**小财管家已完成任务拆解。**',
'### 我会这样推进',
'',
normalized.summary || `我识别到 ${normalized.tasks.length}待处理任务,请确认后继续执行。`,
`我识别到 **${normalized.tasks.length}财务事项**,会按顺序逐步处理,不会一次性把所有动作都执行`,
'',
...taskLines
].join('\n')
...taskLines,
'',
'如果这个顺序没问题,请回复 **确定**。我会先进入第一步,并在具体步骤里再判断需要你补充哪些信息。'
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
}
export function buildStewardFieldItems(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const seen = new Set()
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
.map((field) => resolveFieldDisplay(field, taskType))
}
export function formatStewardMissingFieldList(fields = [], taskType = '') {
return buildStewardFieldItems(fields, taskType)
.map((item) => item.hint ? `${item.label}${item.hint}` : item.label)
.join('、')
}
export function formatStewardOntologyFields(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return `${field.label}${value}`
})
.join('')
}
export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan)
const taskById = new Map(normalized.tasks.map((task) => [task.taskId, task]))
const groupById = new Map(normalized.attachmentGroups.map((group) => [group.groupId, group]))
return normalized.confirmationGroups.map((action) => {
const actionType = String(action.action_type || action.actionType || '').trim()
const taskId = String(action.target_task_id || action.targetTaskId || '').trim()
const groupId = String(action.attachment_group_id || action.attachmentGroupId || '').trim()
const task = taskById.get(taskId)
const group = groupById.get(groupId)
const targetSessionType = actionType === 'confirm_create_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
return {
label: String(action.label || '确认继续处理'),
description: String(action.description || ''),
const nextContext = resolveNextActionContext(normalized)
if (!nextContext) {
return []
}
const { action, actionType, task, group } = nextContext
const targetSessionType = actionType === 'confirm_create_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
return [
{
label: buildNextActionLabel(actionType),
description: buildNextActionDescription(actionType, normalized, task, group),
icon: actionType === 'confirm_create_application'
? 'mdi mdi-file-plus-outline'
: actionType === 'confirm_attachment_group'
@@ -135,17 +240,198 @@ export function buildStewardSuggestedActions(plan) {
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardCarryText(actionType, task, group),
carry_text: buildStewardCarryText(actionType, task, group, normalized),
carry_files: actionType !== 'confirm_create_application',
auto_submit: true,
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
steward_plan_id: normalized.planId
steward_plan_id: normalized.planId,
steward_next_task_id: task?.taskId || '',
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
}
}
})
]
}
function buildStewardCarryText(actionType, task, group) {
function resolveNextActionContext(normalized) {
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
const applicationAction = applicationTask
? findConfirmationAction(normalized, 'confirm_create_application', applicationTask.taskId)
: null
if (applicationAction) {
return {
action: applicationAction,
actionType: 'confirm_create_application',
task: applicationTask,
group: null
}
}
const reimbursementTask = normalized.tasks.find((task) => task.taskType === 'reimbursement')
const reimbursementAction = reimbursementTask
? findConfirmationAction(normalized, 'confirm_create_reimbursement_draft', reimbursementTask.taskId)
: null
if (reimbursementAction) {
return {
action: reimbursementAction,
actionType: 'confirm_create_reimbursement_draft',
task: reimbursementTask,
group: findAttachmentGroupForTask(normalized, reimbursementTask.taskId)
}
}
const attachmentAction = normalized.confirmationGroups.find((action) =>
normalizeActionType(action) === 'confirm_attachment_group'
)
if (attachmentAction) {
const groupId = String(attachmentAction.attachment_group_id || attachmentAction.attachmentGroupId || '').trim()
const group = normalized.attachmentGroups.find((item) => item.groupId === groupId)
const task = normalized.tasks.find((item) => item.taskId === group?.targetTaskId)
return {
action: attachmentAction,
actionType: 'confirm_attachment_group',
task,
group
}
}
const fallbackAction = normalized.confirmationGroups[0]
if (!fallbackAction) {
return null
}
const actionType = normalizeActionType(fallbackAction)
const taskId = String(fallbackAction.target_task_id || fallbackAction.targetTaskId || '').trim()
return {
action: fallbackAction,
actionType,
task: normalized.tasks.find((task) => task.taskId === taskId),
group: null
}
}
function findConfirmationAction(normalized, actionType, taskId) {
return normalized.confirmationGroups.find((action) =>
normalizeActionType(action) === actionType
&& String(action.target_task_id || action.targetTaskId || '').trim() === taskId
) || normalized.confirmationGroups.find((action) => normalizeActionType(action) === actionType)
}
function findAttachmentGroupForTask(normalized, taskId) {
return normalized.attachmentGroups.find((group) => group.targetTaskId === taskId)
|| normalized.attachmentGroups[0]
|| null
}
function normalizeActionType(action) {
return String(action?.action_type || action?.actionType || '').trim()
}
function buildStewardExecutionSummary(normalized) {
const attachmentCount = normalized.attachmentGroups
.reduce((total, group) => total + group.attachmentNames.length, 0)
const summary = [`我识别到 **${normalized.tasks.length} 个待处理任务**`]
if (attachmentCount) {
summary.push(`并形成 ${attachmentCount} 份附件的归集建议`)
}
summary.push(`${buildTaskOrderDescription(normalized)}`)
return summary.join('')
}
function buildOrderedStewardTasks(normalized, nextTask = null) {
if (!nextTask?.taskId) {
return normalized.tasks
}
return [
nextTask,
...normalized.tasks.filter((task) => task.taskId !== nextTask.taskId)
]
}
function buildTaskOrderVerb(index) {
if (index === 0) {
return '先'
}
if (index === 1) {
return '再'
}
return '然后'
}
function buildTaskOrderTarget(task) {
const title = task.title || task.taskTypeLabel
if (task.taskType === 'expense_application') {
return `创建“${title}`
}
if (task.taskType === 'reimbursement') {
return `处理“${title}`
}
return `处理“${title}`
}
function buildTaskOrderActionDescription(task) {
const agent = task.assignedAgentLabel || '对应助手'
if (task.taskType === 'expense_application') {
return `交给${agent}生成申请单核对结果,确认无误后再进入后续动作。`
}
if (task.taskType === 'reimbursement') {
return `交给${agent}整理报销核对结果,等前一步完成后再继续推进。`
}
return `交给${agent}处理,执行前会先让你确认。`
}
function buildTaskOrderDescription(normalized) {
const hasApplication = normalized.tasks.some((task) => task.taskType === 'expense_application')
const hasReimbursement = normalized.tasks.some((task) => task.taskType === 'reimbursement')
if (hasApplication && hasReimbursement) {
return '处理顺序是:先创建申请单,再引导填写报销单。'
}
if (hasApplication) {
return '我会先引导创建申请单并等待你确认。'
}
if (hasReimbursement) {
return '我会引导填写报销单并等待你确认。'
}
return '我会按识别顺序逐项推进,并在执行前等待你确认。'
}
function buildNextTaskLead(task) {
if (task.taskType === 'expense_application') {
return `先创建“${task.title || task.taskTypeLabel}`
}
if (task.taskType === 'reimbursement') {
return `继续填写“${task.title || task.taskTypeLabel}`
}
return `处理“${task.title || task.taskTypeLabel}`
}
function buildNextActionLabel(actionType) {
if (actionType === 'confirm_create_application') {
return '确定,先创建申请单'
}
if (actionType === 'confirm_attachment_group') {
return '确定,确认附件归集'
}
return '确定,继续填写报销单'
}
function buildNextActionDescription(actionType, normalized, task, group) {
const remainingCount = normalized.tasks.filter((item) => item.taskId !== task?.taskId).length
if (actionType === 'confirm_create_application') {
return remainingCount > 0
? '申请助手会先生成申请单核对结果,完成后再继续引导后续报销。'
: '申请助手会生成申请单核对结果,入库前仍需确认。'
}
if (actionType === 'confirm_attachment_group') {
return group?.attachmentNames?.length
? `先归集 ${group.attachmentNames.length} 份附件,再进入报销核对。`
: '先确认附件归集,再进入报销核对。'
}
return group?.attachmentNames?.length
? `报销助手会带入 ${group.attachmentNames.length} 份相关附件生成核对结果。`
: '报销助手会根据当前任务生成报销核对结果。'
}
function buildStewardCarryText(actionType, task, group, normalized = null) {
if (actionType === 'confirm_attachment_group' && group) {
return [
`我确认将以下附件归集为${group.sceneLabel || '当前报销任务'},请继续整理报销核对信息。`,
@@ -160,14 +446,79 @@ function buildStewardCarryText(actionType, task, group) {
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
}
const fields = Object.entries(task.ontologyFields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => `${key}: ${value}`)
return [
`我确认处理“小财管家”识别的任务${task.title || task.taskTypeLabel}`,
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
const missingFields = formatStewardMissingFieldList(task.missingFields || [], task.taskType)
const lines = [
actionType === 'confirm_create_application'
? `小财管家已完成意图识别,请先创建申请单${task.title || task.taskTypeLabel}`
: `小财管家已完成意图识别,请继续填写报销单:${task.title || task.taskTypeLabel}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields.length ? `本体字段${fields.join('')}` : '',
task.missingFields.length ? `待补充字段${task.missingFields.join('、')}` : '',
'请按现有流程生成核对结果,并在需要入库、绑定附件或提交审批前让我再次确认。'
].filter(Boolean).join('\n')
fields ? `已识别信息${fields}` : '',
group?.attachmentNames?.length ? `相关附件${group.attachmentNames.join('、')}` : '',
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
actionType === 'confirm_create_application'
? '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
]
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
if (remainingTaskText) {
lines.push(remainingTaskText)
}
return lines.filter(Boolean).join('\n')
}
function normalizeFieldKey(field) {
const key = String(field || '').trim()
return FIELD_ALIASES[key] || key
}
function resolveFieldDisplay(field, taskType = '') {
const key = normalizeFieldKey(field)
const config = FIELD_DISPLAY_CONFIG[key] || {
label: key.replace(/_/g, ' '),
hint: ''
}
if (key === 'amount') {
return {
key,
label: taskType === 'expense_application' ? '预计金额' : '报销金额',
hint: taskType === 'expense_application'
? '本次申请预计发生的费用'
: '本次需要报销的实际金额'
}
}
return {
key,
label: config.label,
hint: config.hint
}
}
function buildRemainingTaskText(normalized, currentTaskId) {
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
if (!remainingTasks.length) {
return ''
}
const taskLines = remainingTasks.map((task, index) =>
`${index + 1}. ${task.title || task.taskTypeLabel}${task.assignedAgentLabel}${task.summary || '待继续核对'}`
)
return [
'当前步骤完成后,请继续引导我处理后续任务:',
...taskLines
].join('\n')
}
function buildRemainingTaskPayload(normalized, currentTaskId) {
return normalized.tasks
.filter((task) => task.taskId !== currentTaskId)
.map((task) => ({
task_id: task.taskId,
task_type: task.taskType,
title: task.title,
summary: task.summary,
assigned_agent: task.assignedAgent,
ontology_fields: task.ontologyFields || {},
missing_fields: task.missingFields || []
}))
}

View File

@@ -6,6 +6,9 @@ import {
} from './stewardPlanModel.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14
export function useStewardPlanFlow({
activeSessionType,
attachedFiles,
@@ -21,17 +24,28 @@ export function useStewardPlanFlow({
replaceMessage,
scrollToBottom,
adjustComposerTextareaHeight,
executeStewardSuggestedAction,
submitting,
reviewActionBusy,
sessionSwitchBusy,
toast
}) {
const stewardTypewriterTimers = new Map()
let stewardTypewriterRunId = 0
let stewardThinkingQueue = Promise.resolve()
function isStewardSession() {
return String(activeSessionType.value || '').trim() === SESSION_TYPE_STEWARD
}
function clearStewardThinkingTimers() {
// 保留给页面卸载调用;流式版不再使用前端延时器。
stewardTypewriterRunId += 1
stewardThinkingQueue = Promise.resolve()
for (const [timerId, resolve] of stewardTypewriterTimers.entries()) {
globalThis.clearTimeout(timerId)
resolve()
}
stewardTypewriterTimers.clear()
}
async function submitStewardPlan(options = {}) {
@@ -44,7 +58,26 @@ export function useStewardPlanFlow({
const fileNames = files.map((file) => file.name).filter(Boolean)
const userText = String(options.userText || rawText || `我上传了 ${fileNames.length} 份附件,请小财管家先归集任务。`).trim()
if (isStewardConfirmationText(rawText) && !files.length) {
const pendingContext = findPendingStewardAction()
if (pendingContext) {
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText))
}
pendingContext.action.confirmedByText = true
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
await executeStewardSuggestedAction?.(pendingContext.message, pendingContext.action)
return true
}
}
submitting.value = true
const streamRunId = beginStewardStreamRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
@@ -75,23 +108,34 @@ export function useStewardPlanFlow({
files,
currentUser: currentUser.value || {}
})
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload)
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload, streamRunId)
await waitForStewardThinkingQueue(streamRunId)
const typedThinkingEvents = resolveStewardThinkingEvents(pendingMessage.id)
const normalizedPlan = normalizeStewardPlan(plan, {
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true
})
replaceMessage(pendingMessage.id, createMessage('assistant', buildStewardPlanMessageText(plan), [], {
if (typedThinkingEvents.length) {
normalizedPlan.thinkingEvents = typedThinkingEvents
normalizedPlan.visibleThinkingEventCount = Number.MAX_SAFE_INTEGER
}
const finalText = buildStewardPlanMessageText(plan)
const suggestedActions = buildStewardSuggestedActions(plan)
replaceMessage(pendingMessage.id, createMessage('assistant', '', [], {
id: pendingMessage.id,
assistantName: '小财管家',
meta: ['小财管家', '等待确认'],
meta: ['小财管家', '输出中'],
stewardPlan: {
...normalizedPlan,
streamStatus: 'completed'
},
suggestedActions: buildStewardSuggestedActions(plan)
streamStatus: 'typing'
}
}))
await typeStewardPlanText(pendingMessage.id, finalText, normalizedPlan, suggestedActions, streamRunId)
persistSessionState()
nextTick(scrollToBottom)
} catch (error) {
replaceMessage(pendingMessage.id, createMessage('assistant', error?.message || '小财管家规划失败,请稍后重试。', [], {
id: pendingMessage.id,
assistantName: '小财管家',
meta: ['小财管家', '规划失败']
}))
@@ -110,12 +154,76 @@ export function useStewardPlanFlow({
return true
}
function fetchPlanWithStreaming(messageId, requestPayload) {
function beginStewardStreamRun() {
const runId = stewardTypewriterRunId + 1
stewardTypewriterRunId = runId
stewardThinkingQueue = Promise.resolve()
return runId
}
async function typeStewardPlanText(messageId, finalText, normalizedPlan, suggestedActions = [], runId = stewardTypewriterRunId) {
const chars = Array.from(String(finalText || ''))
const total = chars.length
let index = 0
while (index < total) {
if (runId !== stewardTypewriterRunId) {
return
}
await waitStewardTypewriterTick(STEWARD_TYPEWRITER_INTERVAL_MS)
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
}
message.text = chars.slice(0, index).join('')
message.meta = ['小财管家', '输出中']
message.stewardPlan = {
...(message.stewardPlan || normalizedPlan),
...normalizedPlan,
streamStatus: 'typing'
}
if (index % 4 === 0 || index === total) {
nextTick(scrollToBottom)
}
}
const message = messages.value.find((item) => item.id === messageId)
if (!message || runId !== stewardTypewriterRunId) {
return
}
message.text = finalText
message.meta = ['小财管家', '等待用户确认']
message.stewardPlan = {
...(message.stewardPlan || normalizedPlan),
...normalizedPlan,
streamStatus: 'completed'
}
message.suggestedActions = suggestedActions
persistSessionState()
nextTick(scrollToBottom)
}
function waitStewardTypewriterTick(intervalMs = STEWARD_TYPEWRITER_INTERVAL_MS) {
return new Promise((resolve) => {
const timerId = globalThis.setTimeout(() => {
stewardTypewriterTimers.delete(timerId)
resolve()
}, intervalMs)
stewardTypewriterTimers.set(timerId, resolve)
})
}
function fetchPlanWithStreaming(messageId, requestPayload, runId) {
if (typeof fetchStewardPlanStream === 'function') {
return fetchStewardPlanStream(requestPayload, {
onEvent: (event) => handleStreamEvent(messageId, event)
onEvent: (event) => handleStreamEvent(messageId, event, runId)
}, {
timeoutMs: 20000,
timeoutMs: 12000,
idleTimeoutMs: 120000,
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
})
}
@@ -126,10 +234,68 @@ export function useStewardPlanFlow({
})
}
function handleStreamEvent(messageId, event) {
function handleStreamEvent(messageId, event, runId = stewardTypewriterRunId) {
if (event.event !== 'thinking') {
return
}
stewardThinkingQueue = stewardThinkingQueue
.then(() => typeStewardThinkingEvent(messageId, event.data, runId))
.catch(() => {})
}
async function typeStewardThinkingEvent(messageId, eventData, runId) {
if (runId !== stewardTypewriterRunId) {
return
}
const eventId = String(eventData?.event_id || eventData?.eventId || `thinking-${Date.now()}`).trim()
const title = String(eventData?.title || '').trim()
const fullContent = String(eventData?.content || '').trim()
appendStewardThinkingEvent(messageId, {
...eventData,
event_id: eventId,
eventId,
title,
content: '',
status: 'running'
}, runId)
const chars = Array.from(fullContent)
let index = 0
while (index < chars.length) {
if (runId !== stewardTypewriterRunId) {
return
}
await waitStewardTypewriterTick(STEWARD_THINKING_TYPEWRITER_INTERVAL_MS)
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
}
updateStewardThinkingEvent(messageId, eventId, fullContent, 'completed', runId)
persistSessionState()
}
function waitForStewardThinkingQueue(runId) {
return stewardThinkingQueue.then(() => {
if (runId !== stewardTypewriterRunId) {
return
}
})
}
function resolveStewardThinkingEvents(messageId) {
const message = messages.value.find((item) => item.id === messageId)
return Array.isArray(message?.stewardPlan?.thinkingEvents)
? message.stewardPlan.thinkingEvents
: []
}
function appendStewardThinkingEvent(messageId, eventData, runId) {
if (runId !== stewardTypewriterRunId) {
return
}
const message = messages.value.find((item) => item.id === messageId)
if (!message?.stewardPlan) return
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
@@ -137,7 +303,7 @@ export function useStewardPlanFlow({
: []
const normalizedPlan = normalizeStewardPlan({
...message.stewardPlan,
thinking_events: [...existingEvents, event.data]
thinking_events: [...existingEvents, eventData]
}, {
visibleThinkingEventCount: existingEvents.length + 1
})
@@ -150,6 +316,76 @@ export function useStewardPlanFlow({
nextTick(scrollToBottom)
}
function updateStewardThinkingEvent(messageId, eventId, content, status, runId) {
if (runId !== stewardTypewriterRunId) {
return
}
const message = messages.value.find((item) => item.id === messageId)
if (!message?.stewardPlan) return
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
? message.stewardPlan.thinkingEvents
: []
const nextEvents = existingEvents.map((item) => (
String(item.eventId || item.event_id || '').trim() === eventId
? {
...item,
content,
status
}
: item
))
const normalizedPlan = normalizeStewardPlan({
...message.stewardPlan,
thinking_events: nextEvents
}, {
visibleThinkingEventCount: nextEvents.length
})
message.stewardPlan = {
...message.stewardPlan,
...normalizedPlan,
streamStatus: 'streaming'
}
if (content.length % 4 === 0 || status === 'completed') {
nextTick(scrollToBottom)
}
}
function findPendingStewardAction() {
for (const message of [...messages.value].reverse()) {
if (
message?.role === 'assistant'
&& isPendingStewardActionMessage(message)
&& !message.suggestedActionsLocked
&& Array.isArray(message.suggestedActions)
&& message.suggestedActions.length
) {
return {
message,
action: message.suggestedActions[0]
}
}
}
return null
}
function isPendingStewardActionMessage(message) {
if (message?.stewardPlan) {
return message.stewardPlan.streamStatus !== 'streaming'
}
return (
String(message?.assistantName || '').trim() === '小财管家'
&& Array.isArray(message?.suggestedActions)
&& message.suggestedActions.some((action) =>
String(action?.payload?.steward_plan_id || '').trim()
)
)
}
function isStewardConfirmationText(value) {
const normalized = String(value || '').replace(/\s+/g, '')
return /^(确定|确认|可以|好的|好|继续|继续执行|执行|开始执行|没问题|同意)$/.test(normalized)
}
return {
isStewardSession,
submitStewardPlan,

View File

@@ -5,6 +5,7 @@ import {
} from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationBusinessTimeContext,
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
@@ -12,6 +13,7 @@ import {
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
normalizeApplicationPreview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
@@ -24,6 +26,11 @@ import {
shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
export function useTravelReimbursementSubmitComposer(ctx) {
const {
MAX_ATTACHMENTS,
@@ -108,6 +115,228 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const pendingAttachmentAssociations = new Map()
function isStewardDelegatedRun(options = {}) {
return Boolean(options?.stewardContinuation && typeof options.stewardContinuation === 'object')
}
function resolveStewardDelegatedActionLabel(sessionType = '') {
return String(sessionType || '').trim() === 'application'
? '申请单核对'
: '报销单核对'
}
function buildStewardDelegatedPlan(continuation = null, thinkingEvents = [], streamStatus = 'streaming') {
return {
planId: String(continuation?.planId || continuation?.plan_id || 'steward_delegation').trim(),
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function resolveApplicationPreviewMissingFieldsForSteward(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
}
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
if (!missingFields.includes('出行方式')) {
return []
}
const iconMap = {
火车: 'mdi mdi-train',
飞机: 'mdi mdi-airplane',
轮船: 'mdi mdi-ferry'
}
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label: mode,
description: `选择${mode}作为本次出行方式,并同步费用测算。`,
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
payload: {
field_key: 'transportMode',
field_label: '出行方式',
value: mode
}
}))
}
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
'',
`**还需要你补充:${missingFields.join('、')}。**`,
'',
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
].join('\n')
}
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
const events = [
{
eventId: `${eventPrefix}-confirm`,
title: '接收确认',
content: '已收到你的确认,小财管家继续推进当前任务。'
},
{
eventId: `${eventPrefix}-coordinate`,
title: '协调能力',
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
}
]
const applicationMissingFields = context.applicationPreview
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
: []
if (applicationMissingFields.length) {
events.push({
eventId: `${eventPrefix}-gap`,
title: '识别缺口',
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
})
}
events.push(
{
eventId: `${eventPrefix}-output`,
title: '准备输出',
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
}
)
return events
}
function resolveStewardDelegatedFinalMeta(finalExtras = {}) {
const sourceMeta = Array.isArray(finalExtras.meta) ? finalExtras.meta : []
const sourceLabel = sourceMeta.find((item) =>
String(item || '').trim() && String(item || '').trim() !== STEWARD_ASSISTANT_NAME
)
const requiresConfirmation = Boolean(
finalExtras.applicationPreview ||
finalExtras.reviewPayload ||
(Array.isArray(finalExtras.suggestedActions) && finalExtras.suggestedActions.length)
)
return [
STEWARD_ASSISTANT_NAME,
requiresConfirmation ? '等待用户确认' : '已完成',
sourceLabel || ''
].filter(Boolean).slice(0, 3)
}
function waitStewardDelegatedTick(intervalMs) {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, intervalMs)
})
}
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
}
message.text = ''
message.assistantName = STEWARD_ASSISTANT_NAME
message.meta = [STEWARD_ASSISTANT_NAME, '思考中']
message.suggestedActions = []
message.stewardContinuation = continuation
message.stewardPlan = buildStewardDelegatedPlan(continuation, [], 'streaming')
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
const thinkingEvents = buildStewardDelegatedThinkingEvents(context.sessionType, continuation, context)
for (const eventData of thinkingEvents) {
const event = {
eventId: eventData.eventId,
stage: 'delegated_action',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(String(eventData.content || ''))
for (let index = 0; index < chars.length; index += 1) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
event.content = chars.slice(0, index + 1).join('')
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
event.content = String(eventData.content || '')
event.status = 'completed'
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
persistSessionState()
}
const text = String(finalText || '')
message.text = ''
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
const chars = Array.from(text)
for (let index = 0; index < chars.length; index += 1) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
message.text = chars.slice(0, index + 1).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
nextTick(scrollToBottom)
}
}
Object.assign(message, finalExtras, {
id: messageId,
text,
assistantName: STEWARD_ASSISTANT_NAME,
meta: resolveStewardDelegatedFinalMeta(finalExtras),
stewardContinuation: continuation,
stewardPlan: buildStewardDelegatedPlan(continuation, [...typedEvents], 'completed')
})
persistSessionState()
nextTick(scrollToBottom)
}
function resetStewardDelegatedInsightState() {
resetFlowRun({ startedAt: 0, openDrawer: false })
insightPanelCollapsed.value = true
currentInsight.value = {
intent: 'welcome',
agent: null
}
}
function isSubmittedApplicationDraftPayload(draftPayload) {
return (
String(draftPayload?.draft_type || '').trim() === 'expense_application'
@@ -376,16 +605,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
})
}
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
function buildBackendMessage(rawText, fileNames, ocrSummary = '', sessionTypeOverride = '') {
const parts = []
const normalizedText = String(rawText || '').trim()
const sessionType = String(activeSessionType.value || '').trim()
const sessionType = String(sessionTypeOverride || activeSessionType.value || '').trim()
const isKnowledgeMessage = sessionType === 'knowledge'
if (normalizedText) {
parts.push(normalizedText)
} else if (fileNames.length) {
parts.push(
isKnowledgeSession.value
isKnowledgeMessage
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
: sessionType === 'application'
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
@@ -440,7 +670,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null) {
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
const user = await resolveApplicationPreviewUser()
const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user),
@@ -474,7 +704,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
user_id: user.username || user.name || 'anonymous',
context_json: {
...buildExpenseApplicationOntologyContext(user),
session_type: activeSessionType.value,
session_type: String(sessionTypeOverride || activeSessionType.value || '').trim(),
entry_source: props.entrySource,
user_input_text: rawText
}
@@ -516,7 +746,10 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated)
const appendToCurrentFlow = Boolean(options.appendToCurrentFlow)
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
const effectiveSessionType = String(options.sessionTypeOverride || activeSessionType.value || '').trim()
const stewardDelegated = isStewardDelegatedRun(options)
const effectiveIsKnowledgeSession = effectiveSessionType === 'knowledge'
const normalizedFiles = effectiveIsKnowledgeSession ? [] : Array.from(options.files ?? attachedFiles.value)
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
const files = fileMergeResult.files
const detailScopedClaimId = resolveDetailScopedClaimId()
@@ -551,8 +784,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
detail_scope_claim_id: detailScopedClaimId
}
: optionExtraContext
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
const extraContext = isKnowledgeSession.value
const selectedBusinessTimeContext = effectiveIsKnowledgeSession ? null : buildComposerBusinessTimeContext()
const extraContext = effectiveIsKnowledgeSession
? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
@@ -569,15 +802,15 @@ export function useTravelReimbursementSubmitComposer(ctx) {
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
)
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, {
sessionType: activeSessionType.value,
const waitForExpenseIntentConfirmation = !stewardDelegated && shouldRequestExpenseIntentConfirmation(rawText, {
sessionType: effectiveSessionType,
attachmentCount: files.length,
reviewAction,
hasSelectedExpenseType,
hasConfirmedExpenseIntent
})
const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
sessionType: activeSessionType.value,
const waitForExpenseSceneSelection = !stewardDelegated && !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
sessionType: effectiveSessionType,
attachmentCount: files.length,
reviewAction,
hasSelectedExpenseType
@@ -587,7 +820,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
String(options.userText || '').trim() ||
resolveComposerDisplaySubmitText(rawText) ||
rawText ||
(isKnowledgeSession.value
(effectiveIsKnowledgeSession
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
: resolvedUploadDisposition === 'continue_existing'
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
@@ -596,7 +829,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, {
sessionType: activeSessionType.value,
sessionType: effectiveSessionType,
entrySource: props.entrySource,
budgetContext: props.initialBudgetContext
}) && !reviewAction) {
@@ -627,7 +860,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
})
}
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, {
const scopeGuard = resolveAssistantScopeGuard(rawText, effectiveSessionType, {
attachmentCount: files.length,
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
reviewAction
@@ -652,34 +885,45 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
if (shouldUseLocalApplicationPreview(rawText, {
sessionType: activeSessionType.value,
sessionType: effectiveSessionType,
attachmentCount: files.length,
reviewAction,
systemGenerated
})) {
const intentStartedAt = Date.now()
const reviewStartedAt = intentStartedAt
resetFlowRun()
startFlowStep('intent', {
title: '业务意图识别',
tool: 'ontology.intent_detection',
detail: '正在识别是否为费用申请事项...'
})
startFlowStep('application-review-preview', {
title: '申请信息核对',
tool: 'ontology.application_review',
detail: '正在复核申请信息,并查询交通票价...'
})
if (stewardDelegated) {
resetStewardDelegatedInsightState()
} else {
resetFlowRun()
startFlowStep('intent', {
title: '业务意图识别',
tool: 'ontology.intent_detection',
detail: '正在识别是否为费用申请事项...'
})
startFlowStep('application-review-preview', {
title: '申请信息核对',
tool: 'ontology.application_review',
detail: '正在复核申请信息,并查询交通票价...'
})
}
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
const pendingMessage = createMessage(
'assistant',
'正在复核申请信息,并查询交通票价,请稍候。',
stewardDelegated ? '' : '正在复核申请信息,并查询交通票价,请稍候。',
[],
{
meta: ['模型复核中']
}
stewardDelegated
? {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
stewardContinuation: options.stewardContinuation || null,
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
}
: {
meta: ['模型复核中']
}
)
messages.value.push(pendingMessage)
composerDraft.value = ''
@@ -697,27 +941,50 @@ export function useTravelReimbursementSubmitComposer(ctx) {
submitting.value = true
try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText, selectedBusinessTimeContext)
const reviewStatus = String(meta?.[1] || '').trim()
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
reviewStatus === '模型复核完成'
? '模型复核完成,已生成申请核对表'
: reviewStatus === '模型复核失败'
? '模型复核失败,已生成临时核对表'
: '模型未返回稳定结果,已完成规则兜底核对',
Date.now() - reviewStartedAt
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
rawText,
selectedBusinessTimeContext,
effectiveSessionType
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildLocalApplicationPreviewMessage(applicationPreview),
[],
{
meta,
applicationPreview
}
))
const reviewStatus = String(meta?.[1] || '').trim()
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
reviewStatus === '模型复核完成'
? '模型复核完成,已生成申请核对表'
: reviewStatus === '模型复核失败'
? '模型复核失败,已生成临时核对表'
: '模型未返回稳定结果,已完成规则兜底核对',
Date.now() - reviewStartedAt
)
}
if (stewardDelegated) {
await typeStewardDelegatedMessage(
pendingMessage.id,
buildLocalApplicationPreviewMessage(applicationPreview),
{
meta,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
stewardContinuation: options.stewardContinuation || null
}
)
} else {
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildLocalApplicationPreviewMessage(applicationPreview),
[],
{
meta,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
))
}
persistSessionState()
nextTick(scrollToBottom)
} finally {
@@ -726,7 +993,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return null
}
if (await promptUnlinkedReceiptFolderIfNeeded({
if (!stewardDelegated && await promptUnlinkedReceiptFolderIfNeeded({
detailScopedClaimId,
files,
fileNames,
@@ -741,7 +1008,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
const hasUnsavedReviewDraft = Boolean(
!isKnowledgeSession.value &&
!stewardDelegated &&
!effectiveIsKnowledgeSession &&
files.length &&
activeReviewPayload.value &&
!String(draftClaimId.value || '').trim() &&
@@ -782,7 +1050,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
if (
!isKnowledgeSession.value &&
!stewardDelegated &&
!effectiveIsKnowledgeSession &&
files.length &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
@@ -836,18 +1105,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
if (!appendToCurrentFlow) {
if (stewardDelegated) {
resetStewardDelegatedInsightState()
} else if (!appendToCurrentFlow) {
resetFlowRun()
} else {
clearFlowSimulationTimers()
}
if (isApplicationSubmitOperation) {
if (!stewardDelegated && isApplicationSubmitOperation) {
startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
} else if (rawText && !reviewAction) {
} else if (!stewardDelegated && rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...')
if (waitForExpenseIntentConfirmation) {
startExpenseIntentConfirmationFlowPreview(rawText)
@@ -906,19 +1177,26 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const pendingMessage = createMessage(
'assistant',
options.pendingText || (
isKnowledgeSession.value
stewardDelegated ? '' : options.pendingText || (
effectiveIsKnowledgeSession
? '正在整理财务知识答案...'
: activeSessionType.value === 'application'
: effectiveSessionType === 'application'
? '正在识别申请信息并查询交通票价...'
: activeSessionType.value === 'approval'
: effectiveSessionType === 'approval'
? '正在查询审核上下文并整理风险提示...'
: '正在识别并整理右侧核对信息...'
),
[],
{
meta: ['处理中']
}
stewardDelegated
? {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
stewardContinuation: options.stewardContinuation || null,
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
}
: {
meta: ['处理中']
}
)
messages.value.push(pendingMessage)
@@ -946,14 +1224,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
if (files.length) {
const ocrStartedAt = Date.now()
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
if (!stewardDelegated) {
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
}
if (recognizedAttachmentData) {
ocrPayload = recognizedAttachmentData.ocrPayload
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
if (!stewardDelegated) {
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
}
} else {
try {
ocrPayload = await recognizeOcrFiles(files, {
@@ -964,14 +1246,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
if (!stewardDelegated) {
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
}
} catch (error) {
console.warn('OCR request failed:', error)
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
if (!stewardDelegated) {
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
}
}
}
if (resolvedUploadDisposition === 'continue_existing') {
if (!stewardDelegated && resolvedUploadDisposition === 'continue_existing') {
replaceMessage(pendingMessage.id, {
...pendingMessage,
text: attachmentAssociationConfirmed
@@ -1069,15 +1355,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
extraContext.review_action = 'create_new_claim_from_documents'
}
if (!isApplicationSubmitOperation) {
if (!isApplicationSubmitOperation && !stewardDelegated) {
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
attachmentCount: effectiveFileNames.length,
waitForSceneSelection: waitForExpenseSceneSelection
})
}
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const orchestratorOptions = isKnowledgeSession.value
const backendMessage = buildBackendMessage(
rawText,
effectiveFileNames,
effectiveOcrSummary,
effectiveSessionType
)
const orchestratorOptions = effectiveIsKnowledgeSession
? {
timeoutMs: 75000,
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
@@ -1117,22 +1408,22 @@ export function useTravelReimbursementSubmitComposer(ctx) {
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
...buildClientTimeContext(),
session_type: activeSessionType.value,
session_type: effectiveSessionType,
entry_source: props.entrySource,
user_input_text: systemGenerated ? '' : rawText,
attachment_names: effectiveFileNames,
attachment_count: effectiveFileNames.length,
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
draft_claim_id: effectiveIsKnowledgeSession ? undefined : draftClaimId.value || undefined,
ocr_summary: effectiveOcrSummary,
ocr_documents: effectiveOcrDocuments,
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
...(linkedRequest.value && !effectiveIsKnowledgeSession ? { request_context: linkedRequest.value } : {}),
...extraContext
}
},
orchestratorOptions
)
responsePayload = payload
flowRunId.value = String(payload?.run_id || '').trim()
flowRunId.value = stewardDelegated ? '' : String(payload?.run_id || '').trim()
let flowRunDetail = null
if (flowRunId.value) {
flowRunDetail = await refreshFlowRunDetail()
@@ -1140,7 +1431,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
draftClaimId.value =
isKnowledgeSession.value
effectiveIsKnowledgeSession
? ''
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
@@ -1163,33 +1454,54 @@ export function useTravelReimbursementSubmitComposer(ctx) {
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
reviewPanelScope: resolveReviewPanelScope({
reviewPayload: payload?.result?.review_payload || null,
reviewAction: reviewActionResult,
fileCount: files.length,
rawText
}),
reviewPanelScope: stewardDelegated
? ''
: resolveReviewPanelScope({
reviewPayload: payload?.result?.review_payload || null,
reviewAction: reviewActionResult,
fileCount: files.length,
rawText
}),
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
operationFeedback: buildOperationFeedbackState(operationFeedbackContext)
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
stewardContinuation: options.stewardContinuation || null
})
replaceMessage(pendingMessage.id, assistantMessage)
const nextInsight = buildAgentInsight(
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
if (nextInsight.agent) {
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
if (stewardDelegated) {
await typeStewardDelegatedMessage(
pendingMessage.id,
assistantMessage.text,
{
...assistantMessage,
id: pendingMessage.id,
reviewPanelScope: ''
},
{
sessionType: effectiveSessionType,
stewardContinuation: options.stewardContinuation || null
}
)
resetStewardDelegatedInsightState()
} else {
replaceMessage(pendingMessage.id, assistantMessage)
const nextInsight = buildAgentInsight(
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
if (nextInsight.agent) {
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
}
currentInsight.value = nextInsight
completeFlowResult(payload, flowRunDetail)
}
currentInsight.value = nextInsight
completeFlowResult(payload, flowRunDetail)
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewActionResult)) {
emitSavedDraftRefresh(payload?.result?.draft_payload || null)
}
persistSessionState()
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
const persistComposerFilesToDraft = async () => {
try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
@@ -1216,19 +1528,31 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
} catch (error) {
clearFlowSimulationTimers()
failCurrentFlowStep(error)
if (!stewardDelegated) {
failCurrentFlowStep(error)
}
replaceMessage(
pendingMessage.id,
createMessage(
'assistant',
error?.message || '无法连接后端 Orchestrator请稍后重试。',
[],
{
meta: ['调用失败']
}
stewardDelegated
? {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '调用失败'],
stewardContinuation: options.stewardContinuation || null
}
: {
meta: ['调用失败']
}
)
)
currentInsight.value = buildErrorInsight(error, fileNames)
if (stewardDelegated) {
resetStewardDelegatedInsightState()
} else {
currentInsight.value = buildErrorInsight(error, fileNames)
}
persistSessionState()
} finally {
submitting.value = false

View File

@@ -222,6 +222,10 @@ test('documents center can mark all unread documents as read from toolbar', () =
documentsCenterView,
/function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/
)
assert.match(
documentsCenterView,
/function resolveLiveDocumentRow\(row\) \{[\s\S]*isNewDocument: isNewDocument\(row, viewedDocumentKeys\.value\)[\s\S]*const visibleRows = computed\(\(\) => \{[\s\S]*\.map\(resolveLiveDocumentRow\)/
)
assert.match(documentListSharedStyles, /\.mark-read-btn\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
})