feat(web): AI 工作台意图规划与规划思考模型
- 新增 workbenchAiIntentPlannerModel,基于 LLM function_call 解析建单/草稿/提交意图,区分 model 与 rule_fallback 来源 - 新增 workbenchAiPlanningThinkingModel 合并规划思考事件流,按 eventId 去重合并 - application gate/preview 模型接入意图规划,usePersonalWorkbenchAiMode/useWorkbenchAiStewardFlow/useWorkbenchAiActionRouter 链路适配,支持上下文提交 - steward 服务与 stewardPlanModel 适配新动作结构,receipt-folder-view 微调样式 - 新增 intent-planner-model/application-context-submit/steward-actions-service 测试,更新 gate-model/action-router/plan-message-copy/fast-preview 测试
This commit is contained in:
@@ -549,59 +549,156 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.associate-hint {
|
.associate-hint {
|
||||||
margin: 0;
|
margin: 0 0 2px 0;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-checkbox-list,
|
.receipt-checkbox-list,
|
||||||
.draft-choice-list {
|
.draft-choice-list {
|
||||||
max-height: 360px;
|
max-height: 340px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-checkbox-list :deep(.el-checkbox),
|
.receipt-checkbox-list :deep(.el-checkbox),
|
||||||
.draft-choice {
|
.draft-choice {
|
||||||
min-height: 50px;
|
display: flex !important;
|
||||||
|
min-height: 52px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
padding: 9px 10px;
|
padding: 10px 14px;
|
||||||
border: 1px solid #dbe4ee;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-checkbox-list :deep(.el-checkbox:hover),
|
||||||
|
.draft-choice:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-checkbox-list :deep(.el-checkbox.is-checked) {
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
background: rgba(58, 124, 165, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-checkbox-list :deep(.el-checkbox__label) {
|
.receipt-checkbox-list :deep(.el-checkbox__label) {
|
||||||
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-weight: 750;
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-checkbox-list small,
|
.receipt-checkbox-list small,
|
||||||
.draft-choice small {
|
.draft-choice small {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
font-weight: 650;
|
font-weight: 400;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-choice {
|
.draft-choice {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
gap: 9px;
|
gap: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-choice.active {
|
.draft-choice.active {
|
||||||
border-color: rgba(58, 124, 165, .42);
|
border-color: var(--theme-primary);
|
||||||
background: rgba(58, 124, 165, .07);
|
background: rgba(58, 124, 165, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-choice span {
|
.draft-choice span {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-choice strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global styles for the association dialog */
|
||||||
|
:global(.receipt-associate-dialog.el-dialog) {
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__header) {
|
||||||
|
padding: 20px 24px 12px 24px;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__headerbtn) {
|
||||||
|
top: 20px;
|
||||||
|
right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__title) {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__body) {
|
||||||
|
padding: 0 24px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__footer) {
|
||||||
|
padding: 12px 24px 16px 24px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__footer .apply-btn),
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn) {
|
||||||
|
min-height: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn) {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #334155;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn:hover) {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #94a3b8;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__footer .apply-btn) {
|
||||||
|
background: var(--theme-primary);
|
||||||
|
border-color: var(--theme-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.receipt-associate-dialog .el-dialog__footer .apply-btn:hover:not(:disabled)) {
|
||||||
|
background: var(--theme-primary-active);
|
||||||
|
border-color: var(--theme-primary-active);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
@@ -35,7 +35,20 @@ import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
|
|||||||
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
|
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
|
||||||
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
|
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
|
||||||
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
||||||
import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js'
|
import {
|
||||||
|
isReimbursementCreationIntent
|
||||||
|
} from './workbenchAiApplicationGateModel.js'
|
||||||
|
import {
|
||||||
|
buildRuleFallbackWorkbenchAiIntentPlan,
|
||||||
|
normalizeWorkbenchAiIntentPlan,
|
||||||
|
resolveExecutableTravelApplicationPlan,
|
||||||
|
shouldRequestWorkbenchAiIntentPlan
|
||||||
|
} from './workbenchAiIntentPlannerModel.js'
|
||||||
|
import {
|
||||||
|
buildInitialModelPlanningThinkingEvents,
|
||||||
|
buildModelPlanningProgressSchedule,
|
||||||
|
mergeWorkbenchAiThinkingEvents
|
||||||
|
} from './workbenchAiPlanningThinkingModel.js'
|
||||||
|
|
||||||
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
|
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
|
||||||
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
|
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
|
||||||
@@ -251,11 +264,16 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
applicationFlow,
|
applicationFlow,
|
||||||
assistantDraft,
|
assistantDraft,
|
||||||
attachmentFlow,
|
attachmentFlow,
|
||||||
|
conversationMessages,
|
||||||
|
createInlineMessage,
|
||||||
emit,
|
emit,
|
||||||
expenseFlow,
|
expenseFlow,
|
||||||
focusAiModeInput,
|
focusAiModeInput,
|
||||||
hasInlineAttachmentOcrDetails,
|
hasInlineAttachmentOcrDetails,
|
||||||
|
persistCurrentConversation,
|
||||||
|
replaceInlineMessage,
|
||||||
resolveLatestInlineUserPrompt,
|
resolveLatestInlineUserPrompt,
|
||||||
|
scrollInlineConversationToBottom,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
startInlineConversation,
|
startInlineConversation,
|
||||||
toast,
|
toast,
|
||||||
@@ -560,6 +578,131 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isModelPlannedReimbursementTask(modelPlan = {}) {
|
||||||
|
const tasks = Array.isArray(modelPlan?.tasks) ? modelPlan.tasks : []
|
||||||
|
return tasks.some((task) => {
|
||||||
|
const taskType = String(task?.task_type || task?.taskType || '').trim()
|
||||||
|
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
|
||||||
|
return taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModelPlanningThinkingEvent(messageId, event) {
|
||||||
|
const message = conversationMessages.value.find((item) => item.id === messageId)
|
||||||
|
if (!message) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentPlan = message.stewardPlan || {}
|
||||||
|
message.stewardPlan = {
|
||||||
|
...currentPlan,
|
||||||
|
streamStatus: 'streaming',
|
||||||
|
thinkingEvents: mergeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(message), [event])
|
||||||
|
}
|
||||||
|
persistCurrentConversation()
|
||||||
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function startModelPlanningProgressUpdates(messageId) {
|
||||||
|
const timerIds = buildModelPlanningProgressSchedule().map(({ delayMs, event }) => (
|
||||||
|
globalThis.setTimeout(() => {
|
||||||
|
updateModelPlanningThinkingEvent(messageId, event)
|
||||||
|
}, delayMs)
|
||||||
|
))
|
||||||
|
return () => {
|
||||||
|
timerIds.forEach((timerId) => globalThis.clearTimeout(timerId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startModelPlanningConversation(cleanPrompt, entry = {}) {
|
||||||
|
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
|
||||||
|
conversationId.value = ''
|
||||||
|
conversationMessages.value = []
|
||||||
|
activeConversationTitle.value = ''
|
||||||
|
}
|
||||||
|
activateInlineConversation({
|
||||||
|
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
|
||||||
|
})
|
||||||
|
inlineConversationAutoScrollPinned.value = true
|
||||||
|
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
|
||||||
|
assistantDraft.value = ''
|
||||||
|
removeWorkbenchDateTag()
|
||||||
|
closeWorkbenchDatePicker()
|
||||||
|
filesFlow.clearAiModeFiles()
|
||||||
|
const pendingMessage = createInlineMessage('assistant', '正在识别意图,准备拆解申请、报销和附件任务。', {
|
||||||
|
pending: true,
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'streaming',
|
||||||
|
thinkingEvents: buildInitialModelPlanningThinkingEvents()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
conversationMessages.value.push(pendingMessage)
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
persistCurrentConversation()
|
||||||
|
return pendingMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
|
||||||
|
void applicationFlow.startAiApplicationPreview(
|
||||||
|
travelApplicationRequest.expenseType,
|
||||||
|
travelApplicationRequest.expenseTypeLabel,
|
||||||
|
travelApplicationRequest.sourceText,
|
||||||
|
{
|
||||||
|
userMessage: travelApplicationRequest.sourceText,
|
||||||
|
pushUserMessage: !plannerPendingMessage,
|
||||||
|
pendingMessageId: plannerPendingMessage?.id,
|
||||||
|
ontologyFields: travelApplicationRequest.ontologyFields,
|
||||||
|
autoSubmit: travelApplicationRequest.autoSubmit,
|
||||||
|
autoSaveDraft: travelApplicationRequest.autoSaveDraft
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeModelPlannedWorkbenchIntent(cleanPrompt, entry = {}, files = []) {
|
||||||
|
let intentPlan = null
|
||||||
|
let modelPlan = null
|
||||||
|
const plannerPendingMessage = startModelPlanningConversation(cleanPrompt, entry)
|
||||||
|
const stopPlanningProgressUpdates = startModelPlanningProgressUpdates(plannerPendingMessage.id)
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
modelPlan = await stewardFlow.resolveInlineExecutionPlan(cleanPrompt, entry, files, {
|
||||||
|
pendingMessageId: plannerPendingMessage.id
|
||||||
|
})
|
||||||
|
intentPlan = normalizeWorkbenchAiIntentPlan(modelPlan, { prompt: cleanPrompt })
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('AI mode intent planner failed, using local fallback:', error)
|
||||||
|
const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan(cleanPrompt)
|
||||||
|
const ruleRequest = resolveExecutableTravelApplicationPlan(rulePlan)
|
||||||
|
if (ruleRequest) {
|
||||||
|
sending.value = false
|
||||||
|
startModelPlannedApplicationPreview(ruleRequest, plannerPendingMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stopPlanningProgressUpdates()
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const travelApplicationRequest = resolveExecutableTravelApplicationPlan(intentPlan)
|
||||||
|
if (travelApplicationRequest) {
|
||||||
|
startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModelPlannedReimbursementTask(modelPlan) || isReimbursementCreationIntent(cleanPrompt)) {
|
||||||
|
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', '已识别为报销任务,正在进入报销流程。', {
|
||||||
|
id: plannerPendingMessage.id,
|
||||||
|
stewardPlan: {
|
||||||
|
streamStatus: 'completed',
|
||||||
|
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' }))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || cleanPrompt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files, { pendingMessageId: plannerPendingMessage.id })
|
||||||
|
}
|
||||||
|
|
||||||
function handleAiAnswerMarkdownClick(event) {
|
function handleAiAnswerMarkdownClick(event) {
|
||||||
const target = event?.target
|
const target = event?.target
|
||||||
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
||||||
@@ -594,6 +737,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
|
||||||
|
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isReimbursementCreationIntent(cleanPrompt)) {
|
if (isReimbursementCreationIntent(cleanPrompt)) {
|
||||||
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || '我要报销')
|
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || '我要报销')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||||
AI_APPLICATION_ACTION_SUBMIT
|
AI_APPLICATION_ACTION_SUBMIT
|
||||||
} from '../../services/aiApplicationPreviewActions.js'
|
} from '../../services/aiApplicationPreviewActions.js'
|
||||||
|
import { executeStewardAction } from '../../services/steward.js'
|
||||||
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
|
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
|
||||||
import {
|
import {
|
||||||
mergeComposerPrefill,
|
mergeComposerPrefill,
|
||||||
@@ -25,11 +26,16 @@ export function useWorkbenchAiActionRouter({
|
|||||||
applicationFlow,
|
applicationFlow,
|
||||||
assistantDraft,
|
assistantDraft,
|
||||||
attachmentFlow,
|
attachmentFlow,
|
||||||
|
conversationMessages,
|
||||||
|
createInlineMessage,
|
||||||
emit,
|
emit,
|
||||||
expenseFlow,
|
expenseFlow,
|
||||||
focusAiModeInput,
|
focusAiModeInput,
|
||||||
hasInlineAttachmentOcrDetails,
|
hasInlineAttachmentOcrDetails,
|
||||||
|
persistCurrentConversation,
|
||||||
|
replaceInlineMessage,
|
||||||
resolveLatestInlineUserPrompt,
|
resolveLatestInlineUserPrompt,
|
||||||
|
scrollInlineConversationToBottom,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
startInlineConversation,
|
startInlineConversation,
|
||||||
toast,
|
toast,
|
||||||
@@ -45,6 +51,9 @@ export function useWorkbenchAiActionRouter({
|
|||||||
|
|
||||||
const actionType = String(action?.action_type || '').trim()
|
const actionType = String(action?.action_type || '').trim()
|
||||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
if (actionPayload.steward_execute_action) {
|
||||||
|
return executeInlineStewardAction(action, sourceMessage)
|
||||||
|
}
|
||||||
if (actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION) {
|
if (actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION) {
|
||||||
if (!hasInlineAttachmentOcrDetails(sourceMessage)) {
|
if (!hasInlineAttachmentOcrDetails(sourceMessage)) {
|
||||||
toast('当前消息没有可查看的附件识别明细。')
|
toast('当前消息没有可查看的附件识别明细。')
|
||||||
@@ -162,6 +171,191 @@ export function useWorkbenchAiActionRouter({
|
|||||||
}, action?.payload?.carry_files ? Array.from(selectedFiles.value) : [])
|
}, action?.payload?.carry_files ? Array.from(selectedFiles.value) : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executeInlineStewardAction(action = {}, sourceMessage = null) {
|
||||||
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
const actionType = String(actionPayload.steward_action_type || '').trim()
|
||||||
|
if (!actionType || !actionPayload.steward_current_task) {
|
||||||
|
toast('当前动作缺少可执行任务快照,请重新发起规划。')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (sourceMessage?.suggestedActionsLocked) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (sourceMessage) {
|
||||||
|
sourceMessage.suggestedActionsLocked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingMessage = appendStewardActionMessage(
|
||||||
|
`正在执行:${String(action.label || '小财管家动作').trim() || '小财管家动作'}...`,
|
||||||
|
{ pending: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let precheckResult = null
|
||||||
|
if (actionType === 'submit_application') {
|
||||||
|
precheckResult = await executeStewardAction(
|
||||||
|
buildStewardActionExecutePayload(action, 'run_duplicate_precheck')
|
||||||
|
)
|
||||||
|
if (!isSuccessfulStewardPrecheck(precheckResult)) {
|
||||||
|
finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(precheckResult), {
|
||||||
|
suggestedActions: []
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextJson = precheckResult
|
||||||
|
? { precheck_result: precheckResult.result_payload || precheckResult.resultPayload || {} }
|
||||||
|
: {}
|
||||||
|
const result = await executeStewardAction(
|
||||||
|
buildStewardActionExecutePayload(action, actionType, contextJson)
|
||||||
|
)
|
||||||
|
finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(result), {
|
||||||
|
suggestedActions: buildStewardActionResultActions(result)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (sourceMessage) {
|
||||||
|
sourceMessage.suggestedActionsLocked = false
|
||||||
|
}
|
||||||
|
finalizeStewardActionMessage(
|
||||||
|
pendingMessage,
|
||||||
|
`动作执行失败:${String(error?.message || '请稍后重试。').trim()}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStewardActionExecutePayload(action = {}, actionType = '', contextJson = {}) {
|
||||||
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
const currentTask = actionPayload.steward_current_task || null
|
||||||
|
return {
|
||||||
|
action_type: actionType,
|
||||||
|
message: resolveStewardActionMessage(action),
|
||||||
|
plan_id: String(actionPayload.steward_plan_id || '').trim(),
|
||||||
|
conversation_id: String(actionPayload.conversation_id || actionPayload.conversationId || '').trim() || null,
|
||||||
|
task: currentTask,
|
||||||
|
action_step: resolveStewardActionStep(actionPayload, actionType),
|
||||||
|
confirmed: true,
|
||||||
|
context_json: contextJson,
|
||||||
|
client_trace_id: buildStewardActionClientTraceId(actionPayload, actionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStewardActionMessage(action = {}) {
|
||||||
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
return String(
|
||||||
|
actionPayload.carry_text ||
|
||||||
|
actionPayload.original_message ||
|
||||||
|
resolveLatestInlineUserPrompt?.() ||
|
||||||
|
action.label ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStewardActionStep(actionPayload = {}, actionType = '') {
|
||||||
|
if (String(actionPayload.steward_action_step?.action_type || '').trim() === actionType) {
|
||||||
|
return actionPayload.steward_action_step
|
||||||
|
}
|
||||||
|
const steps = Array.isArray(actionPayload.steward_current_task?.action_steps)
|
||||||
|
? actionPayload.steward_current_task.action_steps
|
||||||
|
: []
|
||||||
|
return steps.find((step) => String(step?.action_type || '').trim() === actionType) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStewardActionClientTraceId(actionPayload = {}, actionType = '') {
|
||||||
|
const planId = String(actionPayload.steward_plan_id || 'steward').trim() || 'steward'
|
||||||
|
const taskId = String(actionPayload.steward_action_task_id || actionPayload.steward_current_task?.task_id || 'task').trim() || 'task'
|
||||||
|
return `${planId}:${taskId}:${actionType}:${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSuccessfulStewardPrecheck(result = {}) {
|
||||||
|
const status = String(result?.status || '').trim()
|
||||||
|
const payload = result?.result_payload || result?.resultPayload || {}
|
||||||
|
return status === 'succeeded' && payload?.blocking !== true
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendStewardActionMessage(content, options = {}) {
|
||||||
|
if (!conversationMessages?.value || typeof createInlineMessage !== 'function') {
|
||||||
|
toast(String(content || '').trim())
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const message = createInlineMessage('assistant', content, options)
|
||||||
|
conversationMessages.value.push(message)
|
||||||
|
persistStewardActionConversation()
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeStewardActionMessage(pendingMessage, content, options = {}) {
|
||||||
|
if (!conversationMessages?.value || typeof createInlineMessage !== 'function') {
|
||||||
|
toast(String(content || '').trim())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const finalMessage = createInlineMessage('assistant', content, {
|
||||||
|
...options,
|
||||||
|
id: pendingMessage?.id
|
||||||
|
})
|
||||||
|
if (pendingMessage?.id && typeof replaceInlineMessage === 'function') {
|
||||||
|
replaceInlineMessage(pendingMessage.id, finalMessage)
|
||||||
|
} else if (pendingMessage?.id) {
|
||||||
|
const index = conversationMessages.value.findIndex((item) => item.id === pendingMessage.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
conversationMessages.value.splice(index, 1, finalMessage)
|
||||||
|
} else {
|
||||||
|
conversationMessages.value.push(finalMessage)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conversationMessages.value.push(finalMessage)
|
||||||
|
}
|
||||||
|
persistStewardActionConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistStewardActionConversation() {
|
||||||
|
persistCurrentConversation?.()
|
||||||
|
scrollInlineConversationToBottom?.({ force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStewardActionResultText(result = {}) {
|
||||||
|
const status = String(result?.status || '').trim()
|
||||||
|
const message = String(result?.message || '').trim()
|
||||||
|
if (status === 'succeeded') {
|
||||||
|
return message || '动作已完成。'
|
||||||
|
}
|
||||||
|
if (status === 'needs_confirmation') {
|
||||||
|
return message || '这一步还需要您确认后才能继续。'
|
||||||
|
}
|
||||||
|
if (status === 'blocked') {
|
||||||
|
return ['这一步暂时不能继续。', message].filter(Boolean).join('\n\n')
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return `动作执行失败:${message || '请稍后重试。'}`
|
||||||
|
}
|
||||||
|
return message || '动作已返回结果。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStewardActionResultActions(result = {}) {
|
||||||
|
if (String(result?.status || '').trim() !== 'succeeded') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const resultPayload = result?.result_payload || result?.resultPayload || {}
|
||||||
|
const draftPayload = resultPayload?.draft_payload || resultPayload?.draftPayload || resultPayload
|
||||||
|
const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
|
||||||
|
const claimId = String(draftPayload?.claim_id || draftPayload?.claimId || '').trim()
|
||||||
|
if (!claimNo && !claimId) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
label: claimNo ? `查看单据 ${claimNo}` : '查看单据',
|
||||||
|
description: '打开刚生成的单据详情。',
|
||||||
|
icon: 'mdi mdi-open-in-new',
|
||||||
|
action_type: 'open_application_detail',
|
||||||
|
payload: {
|
||||||
|
claim_id: claimId,
|
||||||
|
claim_no: claimNo
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleInlineSuggestedAction
|
handleInlineSuggestedAction
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
extractInlineApplicationDraftPayload
|
extractInlineApplicationDraftPayload
|
||||||
} from './workbenchAiApplicationPreviewModel.js'
|
} from './workbenchAiApplicationPreviewModel.js'
|
||||||
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
|
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
|
||||||
|
import {
|
||||||
|
completeWorkbenchAiThinkingEvents,
|
||||||
|
mergeWorkbenchAiThinkingEvents
|
||||||
|
} from './workbenchAiPlanningThinkingModel.js'
|
||||||
import {
|
import {
|
||||||
isOrphanInlineApplicationPreviewMessage,
|
isOrphanInlineApplicationPreviewMessage,
|
||||||
resolveInlineApplicationPreviewTextAction,
|
resolveInlineApplicationPreviewTextAction,
|
||||||
@@ -202,6 +206,26 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasSavedInlineApplicationDraft(message = {}, options = {}) {
|
||||||
|
const draftPayload = message?.draftPayload || options.draftPayload || null
|
||||||
|
if (!draftPayload || typeof draftPayload !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||||
|
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||||
|
const status = String(draftPayload.status || '').trim().toLowerCase()
|
||||||
|
return Boolean((claimId || claimNo) && status !== 'submitted')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContextualInlineApplicationSubmitText(text = '') {
|
||||||
|
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
||||||
|
return Boolean(
|
||||||
|
normalized &&
|
||||||
|
/提交/.test(normalized) &&
|
||||||
|
/(这个|这张|当前|上面|上述|刚才|申请单|单据|草稿|领导|审核|审批)/.test(normalized)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLatestInlineApplicationPreviewMessage() {
|
function resolveLatestInlineApplicationPreviewMessage() {
|
||||||
return resolveLatestApplicationPreviewMessage(conversationMessages.value)
|
return resolveLatestApplicationPreviewMessage(conversationMessages.value)
|
||||||
}
|
}
|
||||||
@@ -334,7 +358,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSubmit && !options.confirmed) {
|
const shouldSubmitSavedDraftDirectly = isSubmit &&
|
||||||
|
!options.confirmed &&
|
||||||
|
hasSavedInlineApplicationDraft(targetMessage, options) &&
|
||||||
|
isContextualInlineApplicationSubmitText(userText)
|
||||||
|
|
||||||
|
if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly) {
|
||||||
requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
|
requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -474,11 +503,16 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
|
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingPendingMessage = options.pendingMessageId
|
||||||
|
? conversationMessages.value.find((item) => item.id === options.pendingMessageId)
|
||||||
|
: null
|
||||||
|
const previousThinkingEvents = completeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(existingPendingMessage))
|
||||||
const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
|
const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
|
||||||
|
id: options.pendingMessageId,
|
||||||
pending: true,
|
pending: true,
|
||||||
stewardPlan: {
|
stewardPlan: {
|
||||||
streamStatus: 'streaming',
|
streamStatus: 'streaming',
|
||||||
thinkingEvents: [
|
thinkingEvents: mergeWorkbenchAiThinkingEvents(previousThinkingEvents, [
|
||||||
{
|
{
|
||||||
eventId: 'application-preview-build',
|
eventId: 'application-preview-build',
|
||||||
title: '整理申请表字段',
|
title: '整理申请表字段',
|
||||||
@@ -491,19 +525,25 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
|
content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
|
||||||
status: 'pending'
|
status: 'pending'
|
||||||
}
|
}
|
||||||
]
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
conversationMessages.value.push(pendingMessage)
|
if (options.pendingMessageId) {
|
||||||
|
replaceInlineMessage(options.pendingMessageId, pendingMessage)
|
||||||
|
} else {
|
||||||
|
conversationMessages.value.push(pendingMessage)
|
||||||
|
}
|
||||||
persistCurrentConversation()
|
persistCurrentConversation()
|
||||||
scrollInlineConversationToBottom()
|
scrollInlineConversationToBottom()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preview = await refreshApplicationPreviewEstimate(
|
const preview = await refreshApplicationPreviewEstimate(
|
||||||
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {})
|
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {}, {
|
||||||
|
ontologyFields: options.ontologyFields
|
||||||
|
})
|
||||||
)
|
)
|
||||||
const content = buildLocalApplicationPreviewMessage(preview)
|
const content = buildLocalApplicationPreviewMessage(preview)
|
||||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, {
|
const previewMessage = createInlineMessage('assistant', content, {
|
||||||
id: pendingMessage.id,
|
id: pendingMessage.id,
|
||||||
applicationPreview: preview,
|
applicationPreview: preview,
|
||||||
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
|
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
|
||||||
@@ -512,7 +552,20 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||||
},
|
},
|
||||||
text: content
|
text: content
|
||||||
}))
|
})
|
||||||
|
replaceInlineMessage(pendingMessage.id, previewMessage)
|
||||||
|
if (options.autoSubmit && normalizeApplicationPreview(preview).readyToSubmit) {
|
||||||
|
await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, previewMessage, {
|
||||||
|
confirmed: true,
|
||||||
|
skipUserMessage: true,
|
||||||
|
userText: options.userMessage || '直接提交'
|
||||||
|
})
|
||||||
|
} else if (options.autoSaveDraft) {
|
||||||
|
await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, {
|
||||||
|
skipUserMessage: true,
|
||||||
|
userText: options.userMessage || '保存草稿'
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
||||||
id: pendingMessage.id,
|
id: pendingMessage.id,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildInlineAttachmentOcrDetails
|
buildInlineAttachmentOcrDetails
|
||||||
} from './workbenchAiMessageModel.js'
|
} from './workbenchAiMessageModel.js'
|
||||||
|
import {
|
||||||
|
completeWorkbenchAiThinkingEvents,
|
||||||
|
mergeWorkbenchAiThinkingEvents
|
||||||
|
} from './workbenchAiPlanningThinkingModel.js'
|
||||||
|
|
||||||
function shouldCheckAiRequiredApplicationGate(prompt) {
|
function shouldCheckAiRequiredApplicationGate(prompt) {
|
||||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||||
@@ -187,13 +191,16 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
return planRequest
|
return planRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInlineStewardStreamEvent(messageId, event) {
|
function handleInlineStewardStreamEvent(messageId, event, options = {}) {
|
||||||
const message = conversationMessages.value.find((item) => item.id === messageId)
|
const message = conversationMessages.value.find((item) => item.id === messageId)
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event?.event === 'answer_delta') {
|
if (event?.event === 'answer_delta') {
|
||||||
|
if (options.includeAnswerDelta === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const data = event?.data && typeof event.data === 'object' ? event.data : {}
|
const data = event?.data && typeof event.data === 'object' ? event.data : {}
|
||||||
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
|
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
|
||||||
appendInlineMessageContent(message, data.delta || data.content || data.text || '')
|
appendInlineMessageContent(message, data.delta || data.content || data.text || '')
|
||||||
@@ -226,16 +233,23 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
scrollInlineConversationToBottom({ force: shouldAutoScroll })
|
scrollInlineConversationToBottom({ force: shouldAutoScroll })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchInlineStewardPlan(messageId, payload) {
|
async function fetchInlineStewardPlan(messageId, payload, options = {}) {
|
||||||
|
const {
|
||||||
|
includeAnswerDelta = true,
|
||||||
|
idleTimeoutMs = 90000,
|
||||||
|
timeoutMs = 0,
|
||||||
|
timeoutMessage = '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。'
|
||||||
|
} = options
|
||||||
try {
|
try {
|
||||||
return await fetchStewardPlanStream(
|
return await fetchStewardPlanStream(
|
||||||
payload,
|
payload,
|
||||||
{
|
{
|
||||||
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event)
|
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event, { includeAnswerDelta })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
idleTimeoutMs: 90000,
|
idleTimeoutMs,
|
||||||
timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。'
|
timeoutMs,
|
||||||
|
timeoutMessage
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -249,23 +263,67 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
|
async function resolveInlineExecutionPlan(prompt, entry = {}, files = [], options = {}) {
|
||||||
|
const receiptContext = await collectAiModeReceiptContext(files)
|
||||||
|
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_execution_plan',
|
||||||
|
source: entry.source || 'workbench',
|
||||||
|
attachment_names: receiptContext.attachmentNames,
|
||||||
|
attachment_count: receiptContext.attachmentCount,
|
||||||
|
ocr_summary: receiptContext.ocrSummary,
|
||||||
|
ocr_documents: receiptContext.ocrDocuments,
|
||||||
|
ocr_source_file_names: receiptContext.ocrSourceFileNames,
|
||||||
|
requested_execution_mode: 'plan_then_execute',
|
||||||
|
...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {})
|
||||||
|
}
|
||||||
|
await attachAiRequiredApplicationGate(planRequest, prompt)
|
||||||
|
const planningMessageId = String(options.pendingMessageId || '').trim()
|
||||||
|
if (planningMessageId) {
|
||||||
|
return fetchInlineStewardPlan(planningMessageId, planRequest, {
|
||||||
|
includeAnswerDelta: false,
|
||||||
|
idleTimeoutMs: 45000,
|
||||||
|
timeoutMs: 35000,
|
||||||
|
timeoutMessage: '小财管家意图规划超时,已切换到本地保守计划。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fetchStewardPlan(planRequest, {
|
||||||
|
timeoutMs: 35000,
|
||||||
|
timeoutMessage: '小财管家意图规划超时,已切换到本地保守计划。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestInlineAssistantReply(prompt, entry = {}, files = [], options = {}) {
|
||||||
let shouldAutoScrollOnFinish = true
|
let shouldAutoScrollOnFinish = true
|
||||||
|
const reusablePendingMessageId = String(options.pendingMessageId || '').trim()
|
||||||
|
const reusablePendingMessage = reusablePendingMessageId
|
||||||
|
? conversationMessages.value.find((item) => item.id === reusablePendingMessageId)
|
||||||
|
: null
|
||||||
|
const previousThinkingEvents = resolveInlineThinkingEvents(reusablePendingMessage)
|
||||||
|
const completedPreviousThinkingEvents = completeWorkbenchAiThinkingEvents(previousThinkingEvents)
|
||||||
const pendingMessage = createInlineMessage('assistant', '', {
|
const pendingMessage = createInlineMessage('assistant', '', {
|
||||||
|
id: reusablePendingMessageId || undefined,
|
||||||
pending: true,
|
pending: true,
|
||||||
stewardPlan: {
|
stewardPlan: {
|
||||||
streamStatus: 'streaming',
|
streamStatus: 'streaming',
|
||||||
thinkingEvents: [
|
thinkingEvents: mergeWorkbenchAiThinkingEvents(completedPreviousThinkingEvents, [
|
||||||
{
|
{
|
||||||
eventId: 'init',
|
eventId: 'init',
|
||||||
title: '小财管家正在接入业务流程',
|
title: '小财管家正在接入业务流程',
|
||||||
content: '正在识别您的意图、上下文和附件信息。',
|
content: '正在识别您的意图、上下文和附件信息。',
|
||||||
status: 'running'
|
status: 'running'
|
||||||
}
|
}
|
||||||
]
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
conversationMessages.value.push(pendingMessage)
|
reusablePendingMessageId ? replaceInlineMessage(reusablePendingMessageId, pendingMessage) : conversationMessages.value.push(pendingMessage)
|
||||||
scrollInlineConversationToBottom()
|
scrollInlineConversationToBottom()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -303,8 +361,8 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
})
|
})
|
||||||
const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
|
const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
|
||||||
const nextThinkingEvents = normalizedPlan.thinkingEvents.length
|
const nextThinkingEvents = normalizedPlan.thinkingEvents.length
|
||||||
? normalizedPlan.thinkingEvents
|
? mergeWorkbenchAiThinkingEvents(previousThinkingEvents, normalizedPlan.thinkingEvents)
|
||||||
: previousThinkingEvents.map((item) => ({ ...item, status: 'completed' }))
|
: completeWorkbenchAiThinkingEvents(previousThinkingEvents)
|
||||||
const previousConversationId = conversationId.value
|
const previousConversationId = conversationId.value
|
||||||
const nextConversationId = String(normalizedPlan.conversationId || '').trim()
|
const nextConversationId = String(normalizedPlan.conversationId || '').trim()
|
||||||
if (nextConversationId) {
|
if (nextConversationId) {
|
||||||
@@ -374,6 +432,7 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
buildRequiredApplicationMissingText,
|
buildRequiredApplicationMissingText,
|
||||||
buildRequiredApplicationSelectionText,
|
buildRequiredApplicationSelectionText,
|
||||||
filterRequiredApplicationCandidates,
|
filterRequiredApplicationCandidates,
|
||||||
|
resolveInlineExecutionPlan,
|
||||||
requestInlineAssistantReply
|
requestInlineAssistantReply
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,45 @@ export function isReimbursementCreationIntent(prompt = '') {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveInlineTravelApplicationRequest(prompt = '') {
|
||||||
|
const sourceText = String(prompt || '').trim()
|
||||||
|
const compact = sourceText.replace(/\s+/g, '')
|
||||||
|
if (!compact) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTravelIntent = /出差|差旅|差旅费|交通/.test(compact)
|
||||||
|
const hasTravelDetail = (
|
||||||
|
/去[\u4e00-\u9fa5A-Za-z0-9]{2,24}/.test(compact) ||
|
||||||
|
/[\u4e00-\u9fa5A-Za-z0-9]{2,24}出差/.test(compact) ||
|
||||||
|
/地点[::]?[\u4e00-\u9fa5A-Za-z0-9]{2,24}/.test(compact) ||
|
||||||
|
/交通(?:方式)?[::]?(火车|高铁|动车|飞机|航班|轮船|轮渡|汽车|网约车|自驾)/.test(compact) ||
|
||||||
|
/(火车|高铁|动车|飞机|航班|轮船|轮渡|汽车|网约车|自驾)/.test(compact)
|
||||||
|
)
|
||||||
|
if (!hasTravelIntent || !hasTravelDetail) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: '差旅费',
|
||||||
|
sourceText,
|
||||||
|
autoSubmit: /直接提交|提交申请|确认提交|提交审批/.test(compact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInlineApplicationPreviewTextAction(text = '') {
|
export function resolveInlineApplicationPreviewTextAction(text = '') {
|
||||||
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
|
if (/^(保存草稿|保存|存草稿|先保存|保存这个单据|保存这个申请|保存这个申请单|保存这张单据|保存这张申请单|保存当前单据|保存当前申请|保存上面的单据|保存刚才的草稿)$/.test(normalized)) {
|
||||||
return AI_APPLICATION_ACTION_SAVE_DRAFT
|
return AI_APPLICATION_ACTION_SAVE_DRAFT
|
||||||
}
|
}
|
||||||
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
|
if (/^(提交|提交申请|确认提交|提交审批|直接提交|提交这个单据|提交这个申请|提交这个申请单|提交这张单据|提交这张申请单|提交当前单据|提交当前申请|提交上面的单据|提交刚才保存的草稿|提交刚才的草稿|把这个单据提交|把这个申请单提交|提交到领导审批|提交给领导审核)$/.test(normalized)) {
|
||||||
return AI_APPLICATION_ACTION_SUBMIT
|
return AI_APPLICATION_ACTION_SUBMIT
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
buildApplicationTemplatePreview,
|
buildApplicationTemplatePreview,
|
||||||
buildLocalApplicationPreview,
|
buildLocalApplicationPreview,
|
||||||
|
buildModelRefinedApplicationPreview,
|
||||||
normalizeApplicationPreview
|
normalizeApplicationPreview
|
||||||
} from '../../utils/expenseApplicationPreview.js'
|
} from '../../utils/expenseApplicationPreview.js'
|
||||||
import {
|
import {
|
||||||
@@ -187,11 +188,74 @@ export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback =
|
|||||||
return `${label}申请`
|
return `${label}申请`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}) {
|
function buildOntologyTimeRangeFromCanonical(value = '') {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const normalized = raw.replace(/\s+/g, ' ')
|
||||||
|
const [startDate, endDate] = normalized
|
||||||
|
.split(/\s*(?:至|到|~|--|—)\s*/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (/^20\d{2}-\d{1,2}-\d{1,2}$/.test(startDate || '')) {
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: /^20\d{2}-\d{1,2}-\d{1,2}$/.test(endDate || '') ? endDate : startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntityFromCanonicalField(type, value) {
|
||||||
|
const normalizedValue = String(value || '').trim()
|
||||||
|
if (!type || !normalizedValue) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
value: normalizedValue,
|
||||||
|
normalized_value: normalizedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApplicationOntologyFromCanonicalFields(fields = {}) {
|
||||||
|
const source = fields && typeof fields === 'object' ? fields : {}
|
||||||
|
const entityKeys = [
|
||||||
|
'expense_type',
|
||||||
|
'location',
|
||||||
|
'reason',
|
||||||
|
'amount',
|
||||||
|
'transport_mode',
|
||||||
|
'customer_name',
|
||||||
|
'merchant_name',
|
||||||
|
'department_name',
|
||||||
|
'employee_name',
|
||||||
|
'employee_no'
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
parse_strategy: 'llm_primary',
|
||||||
|
time_range: buildOntologyTimeRangeFromCanonical(source.time_range),
|
||||||
|
entities: entityKeys
|
||||||
|
.map((key) => buildEntityFromCanonicalField(key, source[key]))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}, options = {}) {
|
||||||
const rawText = String(sourceText || '').trim()
|
const rawText = String(sourceText || '').trim()
|
||||||
const preview = rawText
|
const basePreview = rawText
|
||||||
? buildLocalApplicationPreview(rawText, currentUser)
|
? buildLocalApplicationPreview(rawText, currentUser)
|
||||||
: buildApplicationTemplatePreview(currentUser)
|
: buildApplicationTemplatePreview(currentUser)
|
||||||
|
const preview = options.ontologyFields && typeof options.ontologyFields === 'object'
|
||||||
|
? buildModelRefinedApplicationPreview(
|
||||||
|
basePreview,
|
||||||
|
buildApplicationOntologyFromCanonicalFields(options.ontologyFields),
|
||||||
|
rawText,
|
||||||
|
currentUser
|
||||||
|
)
|
||||||
|
: basePreview
|
||||||
const normalized = normalizeApplicationPreview(preview)
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
return normalizeApplicationPreview({
|
return normalizeApplicationPreview({
|
||||||
...normalized,
|
...normalized,
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { resolveInlineTravelApplicationRequest } from './workbenchAiApplicationGateModel.js'
|
||||||
|
|
||||||
|
export const WORKBENCH_AI_INTENT_SOURCE_MODEL = 'llm_function_call'
|
||||||
|
export const WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK = 'rule_fallback'
|
||||||
|
|
||||||
|
export const WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW = 'build_application_preview'
|
||||||
|
export const WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS = 'validate_required_fields'
|
||||||
|
export const WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT = 'save_application_draft'
|
||||||
|
export const WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK = 'run_duplicate_precheck'
|
||||||
|
export const WORKBENCH_AI_STEP_SUBMIT_APPLICATION = 'submit_application'
|
||||||
|
|
||||||
|
const TRAVEL_APPLICATION_INTENT = 'create_travel_application'
|
||||||
|
|
||||||
|
function normalizePromptAction(prompt = '') {
|
||||||
|
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||||
|
if (/直接提交|提交申请|确认提交|提交审批/.test(compact)) {
|
||||||
|
return 'submit'
|
||||||
|
}
|
||||||
|
if (/保存草稿|保存|存草稿|先保存/.test(compact)) {
|
||||||
|
return 'save_draft'
|
||||||
|
}
|
||||||
|
return 'preview'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlannerSource(value = '') {
|
||||||
|
return String(value || '').trim() === WORKBENCH_AI_INTENT_SOURCE_MODEL
|
||||||
|
? WORKBENCH_AI_INTENT_SOURCE_MODEL
|
||||||
|
: WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSlotKey(key = '') {
|
||||||
|
const normalized = String(key || '').trim()
|
||||||
|
if (['time_range', 'business_time', 'occurred_date', 'application_time'].includes(normalized)) {
|
||||||
|
return 'timeRange'
|
||||||
|
}
|
||||||
|
if (['transport_mode', 'transportType', 'transport_type', 'trafficMode'].includes(normalized)) {
|
||||||
|
return 'transportMode'
|
||||||
|
}
|
||||||
|
if (['business_reason', 'businessPurpose', 'purpose'].includes(normalized)) {
|
||||||
|
return 'reason'
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSlots(rawSlots = {}) {
|
||||||
|
if (!rawSlots || typeof rawSlots !== 'object') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return Object.entries(rawSlots).reduce((slots, [key, value]) => {
|
||||||
|
const normalizedKey = normalizeSlotKey(key)
|
||||||
|
const normalizedValue = String(value || '').trim()
|
||||||
|
if (normalizedKey && normalizedValue) {
|
||||||
|
slots[normalizedKey] = normalizedValue
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONTOLOGY_FIELD_ALIASES = {
|
||||||
|
business_time: 'time_range',
|
||||||
|
occurred_date: 'time_range',
|
||||||
|
application_time: 'time_range',
|
||||||
|
transportType: 'transport_mode',
|
||||||
|
transport_type: 'transport_mode',
|
||||||
|
trafficMode: 'transport_mode',
|
||||||
|
business_reason: 'reason',
|
||||||
|
businessPurpose: 'reason',
|
||||||
|
purpose: 'reason'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_ONTOLOGY_FIELDS = new Set([
|
||||||
|
'expense_type',
|
||||||
|
'time_range',
|
||||||
|
'location',
|
||||||
|
'reason',
|
||||||
|
'amount',
|
||||||
|
'transport_mode',
|
||||||
|
'attachments',
|
||||||
|
'customer_name',
|
||||||
|
'merchant_name',
|
||||||
|
'department_name',
|
||||||
|
'employee_name',
|
||||||
|
'employee_no'
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeOntologyFields(rawFields = {}) {
|
||||||
|
if (!rawFields || typeof rawFields !== 'object') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return Object.entries(rawFields).reduce((fields, [key, value]) => {
|
||||||
|
const normalizedKey = ONTOLOGY_FIELD_ALIASES[String(key || '').trim()] || String(key || '').trim()
|
||||||
|
const normalizedValue = String(value || '').trim()
|
||||||
|
if (SUPPORTED_ONTOLOGY_FIELDS.has(normalizedKey) && normalizedValue) {
|
||||||
|
fields[normalizedKey] = normalizedValue
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApplicationSteps(requestedAction = 'preview') {
|
||||||
|
const steps = [
|
||||||
|
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||||
|
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS
|
||||||
|
]
|
||||||
|
if (requestedAction === 'submit') {
|
||||||
|
steps.push(
|
||||||
|
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
|
||||||
|
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
|
||||||
|
)
|
||||||
|
} else if (requestedAction === 'save_draft') {
|
||||||
|
steps.push(WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT)
|
||||||
|
}
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerApplicationSteps(rawSteps = []) {
|
||||||
|
if (!Array.isArray(rawSteps)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const mappedSteps = rawSteps
|
||||||
|
.map((step) => String(step?.action_type || step?.actionType || '').trim())
|
||||||
|
.map((actionType) => {
|
||||||
|
if (actionType === WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW) {
|
||||||
|
return WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW
|
||||||
|
}
|
||||||
|
if (actionType === WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS) {
|
||||||
|
return WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS
|
||||||
|
}
|
||||||
|
if (actionType === WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT) {
|
||||||
|
return WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
|
||||||
|
}
|
||||||
|
if (actionType === WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK) {
|
||||||
|
return WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK
|
||||||
|
}
|
||||||
|
if (actionType === WORKBENCH_AI_STEP_SUBMIT_APPLICATION) {
|
||||||
|
return WORKBENCH_AI_STEP_SUBMIT_APPLICATION
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
return [...new Set(mappedSteps)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function findModelTravelApplicationTask(rawPlan = {}) {
|
||||||
|
const tasks = Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
|
||||||
|
return tasks.find((task) => {
|
||||||
|
const taskType = String(task?.task_type || task?.taskType || '').trim()
|
||||||
|
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
|
||||||
|
return taskType === 'expense_application' || assignedAgent === 'application_assistant'
|
||||||
|
}) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCandidateFlows(rawPlan = {}) {
|
||||||
|
const pendingFlow = rawPlan?.pending_flow_confirmation || rawPlan?.pendingFlowConfirmation || {}
|
||||||
|
const pendingCandidates = pendingFlow?.candidate_flows || pendingFlow?.candidateFlows
|
||||||
|
const rootCandidates = rawPlan?.candidate_flows || rawPlan?.candidateFlows
|
||||||
|
if (Array.isArray(pendingCandidates)) {
|
||||||
|
return pendingCandidates
|
||||||
|
}
|
||||||
|
return Array.isArray(rootCandidates) ? rootCandidates : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSingleApplicationCandidateFlow(rawPlan = {}) {
|
||||||
|
const pendingFlow = rawPlan?.pending_flow_confirmation || rawPlan?.pendingFlowConfirmation || {}
|
||||||
|
if (String(pendingFlow?.status || '').trim() !== 'pending') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const candidateFlows = resolveCandidateFlows(rawPlan)
|
||||||
|
if (candidateFlows.length !== 1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const [candidate] = candidateFlows
|
||||||
|
const flowId = String(candidate?.flow_id || candidate?.flowId || '').trim()
|
||||||
|
const label = String(candidate?.label || '').trim()
|
||||||
|
if (flowId === 'travel_application' && /先发起出差申请/.test(label)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) {
|
||||||
|
const prompt = String(options.prompt || rawPlan?.sourceText || rawPlan?.source_text || '').trim()
|
||||||
|
const task = findModelTravelApplicationTask(rawPlan)
|
||||||
|
if (!task) {
|
||||||
|
const candidateFlow = findSingleApplicationCandidateFlow(rawPlan)
|
||||||
|
if (!candidateFlow) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const ontologyFields = normalizeOntologyFields(candidateFlow.ontology_fields || candidateFlow.ontologyFields)
|
||||||
|
const requestedAction = normalizePromptAction(prompt)
|
||||||
|
return {
|
||||||
|
source: normalizePlannerSource(rawPlan.planning_source || rawPlan.planningSource),
|
||||||
|
intent: TRAVEL_APPLICATION_INTENT,
|
||||||
|
requestedAction,
|
||||||
|
confidence: Number(candidateFlow.confidence || rawPlan.confidence || 0),
|
||||||
|
sourceText: prompt,
|
||||||
|
ontologyFields,
|
||||||
|
slots: normalizeSlots(ontologyFields),
|
||||||
|
missingFields: Array.isArray(candidateFlow.missing_fields || candidateFlow.missingFields)
|
||||||
|
? candidateFlow.missing_fields || candidateFlow.missingFields
|
||||||
|
: [],
|
||||||
|
steps: buildApplicationSteps(requestedAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOntologyFields = task.ontology_fields || task.ontologyFields || rawPlan.slots
|
||||||
|
const ontologyFields = normalizeOntologyFields(rawOntologyFields)
|
||||||
|
const requestedAction = String(
|
||||||
|
task.requested_action ||
|
||||||
|
task.requestedAction ||
|
||||||
|
rawPlan.requested_action ||
|
||||||
|
rawPlan.requestedAction ||
|
||||||
|
''
|
||||||
|
).trim() || normalizePromptAction(prompt)
|
||||||
|
const serverSteps = normalizeServerApplicationSteps(task.action_steps || task.actionSteps)
|
||||||
|
return {
|
||||||
|
source: normalizePlannerSource(rawPlan.planning_source || rawPlan.planningSource),
|
||||||
|
intent: TRAVEL_APPLICATION_INTENT,
|
||||||
|
requestedAction,
|
||||||
|
confidence: Number(task.confidence || rawPlan.confidence || 0),
|
||||||
|
sourceText: prompt,
|
||||||
|
ontologyFields,
|
||||||
|
slots: normalizeSlots(ontologyFields),
|
||||||
|
missingFields: Array.isArray(task.missing_fields || task.missingFields)
|
||||||
|
? task.missing_fields || task.missingFields
|
||||||
|
: [],
|
||||||
|
steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRuleFallbackWorkbenchAiIntentPlan(prompt = '') {
|
||||||
|
const request = resolveInlineTravelApplicationRequest(prompt)
|
||||||
|
if (!request) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const requestedAction = request.autoSubmit ? 'submit' : normalizePromptAction(prompt)
|
||||||
|
return {
|
||||||
|
source: WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK,
|
||||||
|
intent: TRAVEL_APPLICATION_INTENT,
|
||||||
|
requestedAction,
|
||||||
|
confidence: 0.72,
|
||||||
|
sourceText: request.sourceText,
|
||||||
|
ontologyFields: {},
|
||||||
|
slots: {},
|
||||||
|
missingFields: [],
|
||||||
|
steps: buildApplicationSteps(requestedAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRequestWorkbenchAiIntentPlan(prompt = '') {
|
||||||
|
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||||
|
if (!compact) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (compact.length < 2 || /^[\d\s.,,。::;;!?!?-]+$/.test(compact)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExecutableTravelApplicationPlan(plan = null) {
|
||||||
|
if (!plan || plan.intent !== TRAVEL_APPLICATION_INTENT) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!Array.isArray(plan.steps) || !plan.steps.includes(WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: '差旅费',
|
||||||
|
sourceText: String(plan.sourceText || '').trim(),
|
||||||
|
ontologyFields: normalizeOntologyFields(plan.ontologyFields || {}),
|
||||||
|
autoSubmit: plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION),
|
||||||
|
autoSaveDraft: plan.steps.includes(WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
export function mergeWorkbenchAiThinkingEvents(...groups) {
|
||||||
|
const merged = []
|
||||||
|
const indexById = new Map()
|
||||||
|
groups.flat().filter(Boolean).forEach((event) => {
|
||||||
|
const eventId = String(event.eventId || event.event_id || '').trim()
|
||||||
|
if (!eventId || !indexById.has(eventId)) {
|
||||||
|
if (eventId) {
|
||||||
|
indexById.set(eventId, merged.length)
|
||||||
|
}
|
||||||
|
merged.push(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const index = indexById.get(eventId)
|
||||||
|
merged.splice(index, 1, {
|
||||||
|
...merged[index],
|
||||||
|
...event,
|
||||||
|
eventId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeWorkbenchAiThinkingEvents(events = []) {
|
||||||
|
return events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
status: event.status === 'failed' ? 'failed' : 'completed'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInitialModelPlanningThinkingEvents() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
eventId: 'model-planning-intent',
|
||||||
|
title: '判断办理意图',
|
||||||
|
content: '正在判断这句话是要办申请、做报销、处理附件,还是普通咨询。',
|
||||||
|
status: 'running'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildModelPlanningProgressSchedule() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
delayMs: 900,
|
||||||
|
event: {
|
||||||
|
eventId: 'model-planning-slots',
|
||||||
|
title: '抽取关键信息',
|
||||||
|
content: '正在整理日期、地点、事由、交通方式、附件和“保存/提交”等动作线索。',
|
||||||
|
status: 'running'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delayMs: 2200,
|
||||||
|
event: {
|
||||||
|
eventId: 'model-planning-steps',
|
||||||
|
title: '规划执行步骤',
|
||||||
|
content: '正在生成可执行动作序列,例如填充申请表、校验必填项、保存草稿或发起提交。',
|
||||||
|
status: 'running'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delayMs: 5200,
|
||||||
|
event: {
|
||||||
|
eventId: 'model-planning-tools',
|
||||||
|
title: '匹配业务工具',
|
||||||
|
content: '正在把模型计划映射到白名单工具,避免未确认的提交或附件关联直接产生副作用。',
|
||||||
|
status: 'running'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delayMs: 9000,
|
||||||
|
event: {
|
||||||
|
eventId: 'model-planning-fallback',
|
||||||
|
title: '准备兜底策略',
|
||||||
|
content: '如果模型等待过久或调用失败,会按保守规则继续识别,保证申请、报销和草稿流程还能推进。',
|
||||||
|
status: 'running'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ export function fetchStewardRuntimeDecision(payload, options = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function executeStewardAction(payload, options = {}) {
|
||||||
|
return apiRequest('/steward/actions/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
|
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
|
||||||
const {
|
const {
|
||||||
timeoutMs = 0,
|
timeoutMs = 0,
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ const AGENT_LABELS = {
|
|||||||
expense: '报销助手'
|
expense: '报销助手'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXECUTABLE_STEWARD_ACTION_TYPES = new Set([
|
||||||
|
'save_application_draft',
|
||||||
|
'submit_application',
|
||||||
|
'create_reimbursement_draft',
|
||||||
|
'associate_attachments'
|
||||||
|
])
|
||||||
|
|
||||||
export function buildStewardPlanRequest({
|
export function buildStewardPlanRequest({
|
||||||
rawText = '',
|
rawText = '',
|
||||||
files = [],
|
files = [],
|
||||||
@@ -102,10 +109,12 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
|
|||||||
summary: String(item.summary || ''),
|
summary: String(item.summary || ''),
|
||||||
status: String(item.status || ''),
|
status: String(item.status || ''),
|
||||||
confidence: Number(item.confidence || 0),
|
confidence: Number(item.confidence || 0),
|
||||||
|
requestedAction: String(item.requested_action || item.requestedAction || ''),
|
||||||
ontologyFields: item.ontology_fields || item.ontologyFields || {},
|
ontologyFields: item.ontology_fields || item.ontologyFields || {},
|
||||||
missingFields,
|
missingFields,
|
||||||
missingFieldItems: buildStewardFieldItems(missingFields, taskType),
|
missingFieldItems: buildStewardFieldItems(missingFields, taskType),
|
||||||
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
|
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true,
|
||||||
|
actionSteps: normalizeStewardActionSteps(item.action_steps || item.actionSteps)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
@@ -137,6 +146,26 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStewardActionSteps(rawSteps = []) {
|
||||||
|
if (!Array.isArray(rawSteps)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return rawSteps
|
||||||
|
.map((step) => ({
|
||||||
|
step_id: String(step?.step_id || step?.stepId || ''),
|
||||||
|
action_type: String(step?.action_type || step?.actionType || ''),
|
||||||
|
label: String(step?.label || ''),
|
||||||
|
target_task_id: String(step?.target_task_id || step?.targetTaskId || ''),
|
||||||
|
status: String(step?.status || 'planned'),
|
||||||
|
requires_confirmation: Boolean(step?.requires_confirmation ?? step?.requiresConfirmation),
|
||||||
|
depends_on: Array.isArray(step?.depends_on || step?.dependsOn)
|
||||||
|
? step.depends_on || step.dependsOn
|
||||||
|
: [],
|
||||||
|
payload: step?.payload && typeof step.payload === 'object' ? step.payload : {}
|
||||||
|
}))
|
||||||
|
.filter((step) => step.step_id && step.action_type)
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStewardPlanMessageText(plan) {
|
export function buildStewardPlanMessageText(plan) {
|
||||||
const normalized = normalizeStewardPlan(plan)
|
const normalized = normalizeStewardPlan(plan)
|
||||||
if (isOffTopicPlan(normalized)) {
|
if (isOffTopicPlan(normalized)) {
|
||||||
@@ -353,6 +382,7 @@ export function buildStewardSuggestedActions(plan) {
|
|||||||
const targetSessionType = actionType === 'confirm_create_application'
|
const targetSessionType = actionType === 'confirm_create_application'
|
||||||
? SESSION_TYPE_APPLICATION
|
? SESSION_TYPE_APPLICATION
|
||||||
: SESSION_TYPE_EXPENSE
|
: SESSION_TYPE_EXPENSE
|
||||||
|
const executableStep = resolveExecutableStewardActionStep(task)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: buildNextActionLabel(actionType, task),
|
label: buildNextActionLabel(actionType, task),
|
||||||
@@ -372,6 +402,7 @@ export function buildStewardSuggestedActions(plan) {
|
|||||||
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
|
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_next_task_id: task?.taskId || '',
|
||||||
|
...buildStewardExecuteActionPayload(executableStep, task),
|
||||||
steward_current_task: buildStewardTaskPayload(task),
|
steward_current_task: buildStewardTaskPayload(task),
|
||||||
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
|
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
|
||||||
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
|
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
|
||||||
@@ -380,6 +411,26 @@ export function buildStewardSuggestedActions(plan) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveExecutableStewardActionStep(task = null) {
|
||||||
|
const steps = Array.isArray(task?.actionSteps || task?.action_steps)
|
||||||
|
? task.actionSteps || task.action_steps
|
||||||
|
: []
|
||||||
|
return [...steps].reverse().find((step) => EXECUTABLE_STEWARD_ACTION_TYPES.has(String(step.action_type || step.actionType || ''))) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStewardExecuteActionPayload(step, task) {
|
||||||
|
if (!step) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
steward_execute_action: true,
|
||||||
|
steward_action_type: String(step.action_type || step.actionType || ''),
|
||||||
|
steward_action_step: step,
|
||||||
|
steward_action_requires_confirmation: Boolean(step.requires_confirmation ?? step.requiresConfirmation),
|
||||||
|
steward_action_task_id: task?.taskId || task?.task_id || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePendingFlowConfirmation(rawPlan = {}) {
|
function normalizePendingFlowConfirmation(rawPlan = {}) {
|
||||||
const rawPending = rawPlan.pending_flow_confirmation || rawPlan.pendingFlowConfirmation || {}
|
const rawPending = rawPlan.pending_flow_confirmation || rawPlan.pendingFlowConfirmation || {}
|
||||||
const rawCandidates = Array.isArray(rawPlan.candidate_flows || rawPlan.candidateFlows)
|
const rawCandidates = Array.isArray(rawPlan.candidate_flows || rawPlan.candidateFlows)
|
||||||
@@ -779,7 +830,9 @@ function buildStewardTaskPayload(task) {
|
|||||||
title: task.title || '',
|
title: task.title || '',
|
||||||
summary: task.summary || '',
|
summary: task.summary || '',
|
||||||
assigned_agent: task.assignedAgent || task.assigned_agent || '',
|
assigned_agent: task.assignedAgent || task.assigned_agent || '',
|
||||||
|
requested_action: task.requestedAction || task.requested_action || '',
|
||||||
ontology_fields: task.ontologyFields || task.ontology_fields || {},
|
ontology_fields: task.ontologyFields || task.ontology_fields || {},
|
||||||
missing_fields: task.missingFields || task.missing_fields || []
|
missing_fields: task.missingFields || task.missing_fields || [],
|
||||||
|
action_steps: task.actionSteps || task.action_steps || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
shouldUseBudgetCompileReport
|
shouldUseBudgetCompileReport
|
||||||
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
||||||
|
import {
|
||||||
|
buildInlineApplicationPreview
|
||||||
|
} from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||||
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
|
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
|
||||||
import {
|
import {
|
||||||
ASSISTANT_SCOPE_ACTION_SWITCH,
|
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||||
@@ -140,6 +143,14 @@ const flowScript = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const personalWorkbenchAiModeScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const applicationPreviewFlowScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
function createFlowHarness() {
|
function createFlowHarness() {
|
||||||
return useTravelReimbursementFlow({
|
return useTravelReimbursementFlow({
|
||||||
@@ -241,6 +252,32 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
|||||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI workbench routes compact travel direct-submit planner into application preview auto submit', () => {
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan/)
|
||||||
|
assert.match(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/modelPlan = await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files\)/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/applicationFlow\.startAiApplicationPreview\([\s\S]*travelApplicationRequest\.expenseType[\s\S]*travelApplicationRequest\.expenseTypeLabel[\s\S]*travelApplicationRequest\.sourceText[\s\S]*ontologyFields:\s*travelApplicationRequest\.ontologyFields[\s\S]*autoSubmit:\s*travelApplicationRequest\.autoSubmit/
|
||||||
|
)
|
||||||
|
assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /if \(options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit\)/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /confirmed:\s*true/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /skipUserMessage:\s*true/)
|
||||||
|
})
|
||||||
|
|
||||||
test('unsupported business guidance opens in assistant conversation form', () => {
|
test('unsupported business guidance opens in assistant conversation form', () => {
|
||||||
const conversation = buildUnsupportedBusinessScopeConversation('你好')
|
const conversation = buildUnsupportedBusinessScopeConversation('你好')
|
||||||
|
|
||||||
@@ -366,6 +403,20 @@ test('application preview renders ordered editable rows and submit text uses edi
|
|||||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用:1900元/)
|
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用:1900元/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application preview keeps compact direct-submit command out of business reason', () => {
|
||||||
|
const preview = buildInlineApplicationPreview(
|
||||||
|
'差旅费',
|
||||||
|
'去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
|
||||||
|
{ grade: 'P5' }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(preview.fields.location, '上海')
|
||||||
|
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
|
||||||
|
assert.equal(preview.fields.transportMode, '火车')
|
||||||
|
assert.equal(preview.readyToSubmit, false)
|
||||||
|
assert.deepEqual(preview.missingFields, ['出发时间', '天数'])
|
||||||
|
})
|
||||||
|
|
||||||
test('application estimate builds deterministic mock transport amount and total', () => {
|
test('application estimate builds deterministic mock transport amount and total', () => {
|
||||||
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
|
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
|
||||||
const datedTrainEstimate = buildMockApplicationTransportEstimate({
|
const datedTrainEstimate = buildMockApplicationTransportEstimate({
|
||||||
|
|||||||
54
web/tests/steward-actions-service.test.mjs
Normal file
54
web/tests/steward-actions-service.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { executeStewardAction } from '../src/services/steward.js'
|
||||||
|
|
||||||
|
async function testExecuteStewardActionUsesActionEndpoint() {
|
||||||
|
let capturedUrl = ''
|
||||||
|
let capturedOptions = null
|
||||||
|
|
||||||
|
global.fetch = async (url, options) => {
|
||||||
|
capturedUrl = String(url)
|
||||||
|
capturedOptions = options
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
action_type: 'save_application_draft',
|
||||||
|
status: 'succeeded',
|
||||||
|
message: '申请草稿已保存。',
|
||||||
|
result_payload: {
|
||||||
|
draft_payload: {
|
||||||
|
claim_id: 'claim-action-draft',
|
||||||
|
claim_no: 'A12345678',
|
||||||
|
status: 'draft'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await executeStewardAction({
|
||||||
|
action_type: 'save_application_draft',
|
||||||
|
message: '保存草稿',
|
||||||
|
task: {
|
||||||
|
task_id: 'task-app-1',
|
||||||
|
task_type: 'expense_application'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(capturedUrl, '/api/v1/steward/actions/execute')
|
||||||
|
assert.equal(capturedOptions.method, 'POST')
|
||||||
|
assert.equal(JSON.parse(capturedOptions.body).action_type, 'save_application_draft')
|
||||||
|
assert.equal(payload.status, 'succeeded')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await testExecuteStewardActionUsesActionEndpoint()
|
||||||
|
console.log('steward actions service tests passed')
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -71,3 +71,55 @@ test('steward plan summary guides bare reimbursement intent into scene selection
|
|||||||
assert.match(action.description, /先进入报销助手选择具体费用类型/)
|
assert.match(action.description, /先进入报销助手选择具体费用类型/)
|
||||||
assert.equal(action.payload.carry_text, '我要报销')
|
assert.equal(action.payload.carry_text, '我要报销')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('steward suggested action carries server executable application action step', () => {
|
||||||
|
const plan = {
|
||||||
|
plan_id: 'plan-application-submit',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
task_id: 'task-app-1',
|
||||||
|
task_type: 'expense_application',
|
||||||
|
title: '上海出差申请',
|
||||||
|
assigned_agent: 'application_assistant',
|
||||||
|
requested_action: 'submit',
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'travel',
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
transport_mode: 'train'
|
||||||
|
},
|
||||||
|
missing_fields: [],
|
||||||
|
action_steps: [
|
||||||
|
{ step_id: 'task-app-1:01', action_type: 'fill_application_fields', status: 'planned' },
|
||||||
|
{ step_id: 'task-app-1:02', action_type: 'build_application_preview', status: 'planned' },
|
||||||
|
{ step_id: 'task-app-1:03', action_type: 'validate_required_fields', status: 'planned' },
|
||||||
|
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck', status: 'planned' },
|
||||||
|
{
|
||||||
|
step_id: 'task-app-1:05',
|
||||||
|
action_type: 'submit_application',
|
||||||
|
status: 'pending_confirmation',
|
||||||
|
requires_confirmation: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
confirmation_groups: [
|
||||||
|
{
|
||||||
|
confirmation_id: 'confirm-task-app-1',
|
||||||
|
action_type: 'confirm_create_application',
|
||||||
|
target_task_id: 'task-app-1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
next_action: 'confirm_task'
|
||||||
|
}
|
||||||
|
|
||||||
|
const [action] = buildStewardSuggestedActions(plan)
|
||||||
|
|
||||||
|
assert.equal(action.payload.steward_execute_action, true)
|
||||||
|
assert.equal(action.payload.steward_action_type, 'submit_application')
|
||||||
|
assert.equal(action.payload.steward_action_step.step_id, 'task-app-1:05')
|
||||||
|
assert.equal(action.payload.steward_action_requires_confirmation, true)
|
||||||
|
assert.equal(action.payload.steward_current_task.requested_action, 'submit')
|
||||||
|
assert.equal(action.payload.steward_current_task.action_steps.at(-1).action_type, 'submit_application')
|
||||||
|
})
|
||||||
|
|||||||
@@ -245,3 +245,147 @@ test('workbench standalone draft action asks before creating a new reimbursement
|
|||||||
label: '独立新建报销单'
|
label: '独立新建报销单'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('workbench steward executable submit action runs precheck before submit and writes result message', async () => {
|
||||||
|
const requests = []
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
globalThis.fetch = async (_url, options = {}) => {
|
||||||
|
const body = JSON.parse(String(options.body || '{}'))
|
||||||
|
requests.push(body)
|
||||||
|
if (body.action_type === 'run_duplicate_precheck') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
action_type: 'run_duplicate_precheck',
|
||||||
|
status: 'succeeded',
|
||||||
|
message: '未发现重复或冲突申请,可以继续提交。',
|
||||||
|
result_payload: {
|
||||||
|
status: 'ok',
|
||||||
|
blocking: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
action_type: 'submit_application',
|
||||||
|
status: 'succeeded',
|
||||||
|
message: '申请已提交审批。',
|
||||||
|
result_payload: {
|
||||||
|
draft_payload: {
|
||||||
|
claim_id: 'claim-app-1',
|
||||||
|
claim_no: 'A1BCDEF2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = []
|
||||||
|
let messageSeq = 0
|
||||||
|
const createInlineMessage = (role, content, options = {}) => ({
|
||||||
|
id: options.id || `msg-${++messageSeq}`,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
pending: Boolean(options.pending),
|
||||||
|
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : []
|
||||||
|
})
|
||||||
|
const replaceInlineMessage = (id, nextMessage) => {
|
||||||
|
const index = messages.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) {
|
||||||
|
messages.splice(index, 1, nextMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let persisted = false
|
||||||
|
const router = useWorkbenchAiActionRouter({
|
||||||
|
aiExpenseDraft: { value: null },
|
||||||
|
applicationFlow: {
|
||||||
|
isInlineSuggestedActionDisabled: () => false,
|
||||||
|
executeInlineApplicationPreviewAction: () => {}
|
||||||
|
},
|
||||||
|
assistantDraft: { value: '' },
|
||||||
|
attachmentFlow: {
|
||||||
|
confirmAiAttachmentAssociation: () => {}
|
||||||
|
},
|
||||||
|
conversationMessages: { value: messages },
|
||||||
|
createInlineMessage,
|
||||||
|
emit: () => {},
|
||||||
|
expenseFlow: {
|
||||||
|
linkAiExpenseApplication: () => {},
|
||||||
|
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||||
|
startAiApplicationPreviewFromAction: () => {},
|
||||||
|
startAiExpenseDraft: () => {}
|
||||||
|
},
|
||||||
|
focusAiModeInput: () => {},
|
||||||
|
hasInlineAttachmentOcrDetails: () => false,
|
||||||
|
persistCurrentConversation: () => {
|
||||||
|
persisted = true
|
||||||
|
},
|
||||||
|
replaceInlineMessage,
|
||||||
|
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交',
|
||||||
|
scrollInlineConversationToBottom: () => {},
|
||||||
|
selectedFiles: { value: [] },
|
||||||
|
startInlineConversation: () => {},
|
||||||
|
toast: () => {},
|
||||||
|
toggleInlineAttachmentOcrDetails: () => {}
|
||||||
|
})
|
||||||
|
const sourceMessage = {
|
||||||
|
suggestedActionsLocked: false
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.handleInlineSuggestedAction({
|
||||||
|
label: '确认提交申请',
|
||||||
|
action_type: 'switch_session',
|
||||||
|
payload: {
|
||||||
|
steward_execute_action: true,
|
||||||
|
steward_plan_id: 'plan-submit-1',
|
||||||
|
steward_action_type: 'submit_application',
|
||||||
|
steward_action_requires_confirmation: true,
|
||||||
|
steward_action_step: {
|
||||||
|
step_id: 'task-app-1:05',
|
||||||
|
action_type: 'submit_application',
|
||||||
|
requires_confirmation: true
|
||||||
|
},
|
||||||
|
steward_current_task: {
|
||||||
|
task_id: 'task-app-1',
|
||||||
|
task_type: 'expense_application',
|
||||||
|
assigned_agent: 'application_assistant',
|
||||||
|
title: '上海出差申请',
|
||||||
|
summary: '2026-02-20 至 2026-02-23 去上海出差,交通火车。',
|
||||||
|
requested_action: 'submit',
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'travel',
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
transport_mode: 'train'
|
||||||
|
},
|
||||||
|
missing_fields: [],
|
||||||
|
action_steps: [
|
||||||
|
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck' },
|
||||||
|
{ step_id: 'task-app-1:05', action_type: 'submit_application', requires_confirmation: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
carry_text: '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交'
|
||||||
|
}
|
||||||
|
}, sourceMessage)
|
||||||
|
|
||||||
|
assert.equal(requests.length, 2)
|
||||||
|
assert.equal(requests[0].action_type, 'run_duplicate_precheck')
|
||||||
|
assert.equal(requests[1].action_type, 'submit_application')
|
||||||
|
assert.equal(requests[1].confirmed, true)
|
||||||
|
assert.equal(requests[1].context_json.precheck_result.status, 'ok')
|
||||||
|
assert.equal(sourceMessage.suggestedActionsLocked, true)
|
||||||
|
assert.equal(persisted, true)
|
||||||
|
assert.match(messages.at(-1).content, /申请已提交审批/)
|
||||||
|
assert.equal(messages.at(-1).suggestedActions[0].action_type, 'open_application_detail')
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
166
web/tests/workbench-ai-application-context-submit.test.mjs
Normal file
166
web/tests/workbench-ai-application-context-submit.test.mjs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { useWorkbenchAiApplicationPreviewFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js'
|
||||||
|
|
||||||
|
function createRef(value) {
|
||||||
|
return { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInlineMessage(role, content, options = {}) {
|
||||||
|
return {
|
||||||
|
id: options.id || `msg-${Math.random().toString(16).slice(2)}`,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
text: content,
|
||||||
|
paragraphs: String(content || '').split(/\n+/).filter(Boolean),
|
||||||
|
pending: Boolean(options.pending),
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApplicationPreviewFlowHarness(messages) {
|
||||||
|
const conversationMessages = createRef(messages)
|
||||||
|
const applicationSubmitConfirmOpen = createRef(false)
|
||||||
|
const applicationSubmitConfirmContext = createRef(null)
|
||||||
|
const persisted = createRef(0)
|
||||||
|
|
||||||
|
const flow = useWorkbenchAiApplicationPreviewFlow({
|
||||||
|
activateInlineConversation: () => {},
|
||||||
|
applicationPreviewEditor: createRef({}),
|
||||||
|
applicationSubmitConfirmContext,
|
||||||
|
applicationSubmitConfirmOpen,
|
||||||
|
assistantDraft: createRef(''),
|
||||||
|
cancelApplicationPreviewEditor: () => {},
|
||||||
|
clearAiModeFiles: () => {},
|
||||||
|
closeWorkbenchDatePicker: () => {},
|
||||||
|
commitApplicationPreviewEditor: async () => true,
|
||||||
|
conversationId: createRef('conversation-context-submit'),
|
||||||
|
conversationMessages,
|
||||||
|
conversationStarted: createRef(true),
|
||||||
|
createInlineMessage,
|
||||||
|
currentUser: createRef({ username: 'zhangsan@example.com', name: '张三' }),
|
||||||
|
handleApplicationPreviewEditorKeydown: () => {},
|
||||||
|
inlineConversationAutoScrollPinned: createRef(true),
|
||||||
|
isApplicationPreviewEditing: createRef(false),
|
||||||
|
openApplicationPreviewEditor: () => {},
|
||||||
|
persistCurrentConversation: () => { persisted.value += 1 },
|
||||||
|
pushInlineApplicationActionUserMessage: (text) => {
|
||||||
|
conversationMessages.value.push(createInlineMessage('user', text))
|
||||||
|
},
|
||||||
|
pushInlineUserMessage: (text) => {
|
||||||
|
conversationMessages.value.push(createInlineMessage('user', text))
|
||||||
|
},
|
||||||
|
refreshApplicationPreviewEstimate: async (preview) => preview,
|
||||||
|
removeWorkbenchDateTag: () => {},
|
||||||
|
replaceInlineMessage: (id, nextMessage) => {
|
||||||
|
const index = conversationMessages.value.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) {
|
||||||
|
conversationMessages.value.splice(index, 1, nextMessage)
|
||||||
|
} else {
|
||||||
|
conversationMessages.value.push(nextMessage)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolveApplicationPreviewEditorDateMax: () => '',
|
||||||
|
resolveApplicationPreviewEditorDateMin: () => '',
|
||||||
|
resolveApplicationPreviewEditorControl: () => null,
|
||||||
|
resolveApplicationPreviewEditorOptions: () => [],
|
||||||
|
resolveInlineThinkingEvents: (message) => message?.stewardPlan?.thinkingEvents || [],
|
||||||
|
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,保存草稿',
|
||||||
|
scrollInlineConversationToBottom: () => {},
|
||||||
|
sending: createRef(false),
|
||||||
|
toast: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
applicationSubmitConfirmOpen,
|
||||||
|
conversationMessages,
|
||||||
|
flow,
|
||||||
|
persisted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
const requests = []
|
||||||
|
globalThis.fetch = async (url, options = {}) => {
|
||||||
|
const normalizedUrl = String(url)
|
||||||
|
if (normalizedUrl.includes('/reimbursements/claims')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { items: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizedUrl.includes('/reimbursements/application-preview-action')) {
|
||||||
|
const body = JSON.parse(String(options.body || '{}'))
|
||||||
|
requests.push({ url: normalizedUrl, body })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
status: 'succeeded',
|
||||||
|
result: {
|
||||||
|
draft_payload: {
|
||||||
|
claim_id: 'claim-saved-draft',
|
||||||
|
claim_no: 'A20260220',
|
||||||
|
status: 'submitted',
|
||||||
|
approval_stage: '直属领导审批'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request: ${normalizedUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const previewMessage = createInlineMessage('assistant', '申请核对表', {
|
||||||
|
id: 'application-preview-1',
|
||||||
|
applicationPreview: {
|
||||||
|
readyToSubmit: true,
|
||||||
|
fields: {
|
||||||
|
applicationType: '差旅费用申请',
|
||||||
|
time: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
days: '4天',
|
||||||
|
transportMode: '火车',
|
||||||
|
amount: '1200元'
|
||||||
|
},
|
||||||
|
missingFields: [],
|
||||||
|
validationIssues: []
|
||||||
|
},
|
||||||
|
draftPayload: {
|
||||||
|
claim_id: 'claim-saved-draft',
|
||||||
|
claim_no: 'A20260220',
|
||||||
|
status: 'draft'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const harness = buildApplicationPreviewFlowHarness([
|
||||||
|
createInlineMessage('user', '2026-02-20 至 2026-02-23,去上海出差,交通火车,保存草稿'),
|
||||||
|
previewMessage,
|
||||||
|
createInlineMessage('assistant', '### 申请草稿已保存', {
|
||||||
|
draftPayload: previewMessage.draftPayload
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
const handled = harness.flow.handleInlineApplicationPreviewTextAction(
|
||||||
|
'提交这个单据',
|
||||||
|
createRef(false)
|
||||||
|
)
|
||||||
|
assert.equal(handled, true)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
assert.equal(harness.applicationSubmitConfirmOpen.value, false)
|
||||||
|
assert.equal(requests.length, 1)
|
||||||
|
assert.equal(requests[0].body.context_json.application_edit_claim_id, 'claim-saved-draft')
|
||||||
|
assert.equal(requests[0].body.context_json.application_edit_mode, true)
|
||||||
|
assert.match(harness.conversationMessages.value.at(-1).content, /申请单据已生成/)
|
||||||
|
assert.ok(harness.persisted.value > 0)
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import test from 'node:test'
|
|||||||
import {
|
import {
|
||||||
isOrphanInlineApplicationPreviewMessage,
|
isOrphanInlineApplicationPreviewMessage,
|
||||||
isReimbursementCreationIntent,
|
isReimbursementCreationIntent,
|
||||||
|
resolveInlineTravelApplicationRequest,
|
||||||
resolveInlineApplicationPreviewTextAction,
|
resolveInlineApplicationPreviewTextAction,
|
||||||
resolveLatestApplicationPreviewMessage,
|
resolveLatestApplicationPreviewMessage,
|
||||||
resolveLatestOrphanApplicationPreviewMessage
|
resolveLatestOrphanApplicationPreviewMessage
|
||||||
@@ -28,9 +29,24 @@ test('workbench application gate resolves save and submit text actions consisten
|
|||||||
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
|
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
|
||||||
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
|
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
|
||||||
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
|
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
|
||||||
|
assert.equal(resolveInlineApplicationPreviewTextAction('提交这个单据'), AI_APPLICATION_ACTION_SUBMIT)
|
||||||
|
assert.equal(resolveInlineApplicationPreviewTextAction('提交这个申请单'), AI_APPLICATION_ACTION_SUBMIT)
|
||||||
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
|
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('workbench application gate detects compact travel application direct submit intent', () => {
|
||||||
|
const request = resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: '差旅费',
|
||||||
|
sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
|
||||||
|
autoSubmit: true
|
||||||
|
})
|
||||||
|
assert.equal(resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车')?.autoSubmit, false)
|
||||||
|
assert.equal(resolveInlineTravelApplicationRequest('帮我查询上海差旅标准'), null)
|
||||||
|
})
|
||||||
|
|
||||||
test('workbench application gate resolves latest live or orphan preview message', () => {
|
test('workbench application gate resolves latest live or orphan preview message', () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
{ id: 'user-1', role: 'user', content: '2月去上海出差' },
|
{ id: 'user-1', role: 'user', content: '2月去上海出差' },
|
||||||
|
|||||||
331
web/tests/workbench-ai-intent-planner-model.test.mjs
Normal file
331
web/tests/workbench-ai-intent-planner-model.test.mjs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import {
|
||||||
|
WORKBENCH_AI_INTENT_SOURCE_MODEL,
|
||||||
|
WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK,
|
||||||
|
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||||
|
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT,
|
||||||
|
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
|
||||||
|
WORKBENCH_AI_STEP_SUBMIT_APPLICATION,
|
||||||
|
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||||
|
buildRuleFallbackWorkbenchAiIntentPlan,
|
||||||
|
normalizeWorkbenchAiIntentPlan,
|
||||||
|
resolveExecutableTravelApplicationPlan,
|
||||||
|
shouldRequestWorkbenchAiIntentPlan
|
||||||
|
} from '../src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js'
|
||||||
|
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||||
|
|
||||||
|
const personalWorkbenchAiModeScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const stewardFlowScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const applicationPreviewFlowScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const planningThinkingModelScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/workbenchAiPlanningThinkingModel.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
test('workbench AI intent planner normalizes model travel application submit plan into executable steps', () => {
|
||||||
|
const plan = normalizeWorkbenchAiIntentPlan({
|
||||||
|
planning_source: 'llm_function_call',
|
||||||
|
tasks: [{
|
||||||
|
task_type: 'expense_application',
|
||||||
|
assigned_agent: 'application_assistant',
|
||||||
|
requested_action: 'submit',
|
||||||
|
confidence: 0.91,
|
||||||
|
ontology_fields: {
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
transport_mode: '火车'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}, {
|
||||||
|
prompt: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_MODEL)
|
||||||
|
assert.equal(plan.intent, 'create_travel_application')
|
||||||
|
assert.equal(plan.requestedAction, 'submit')
|
||||||
|
assert.deepEqual(plan.steps, [
|
||||||
|
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||||
|
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||||
|
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
|
||||||
|
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
|
||||||
|
])
|
||||||
|
assert.deepEqual(plan.ontologyFields, {
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
transport_mode: '火车'
|
||||||
|
})
|
||||||
|
assert.deepEqual(plan.slots, {
|
||||||
|
timeRange: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
transportMode: '火车'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI intent planner prefers server action steps when present', () => {
|
||||||
|
const plan = normalizeWorkbenchAiIntentPlan({
|
||||||
|
planning_source: 'llm_function_call',
|
||||||
|
tasks: [{
|
||||||
|
task_type: 'expense_application',
|
||||||
|
assigned_agent: 'application_assistant',
|
||||||
|
requested_action: 'submit',
|
||||||
|
confidence: 0.91,
|
||||||
|
ontology_fields: {
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
transport_mode: '火车'
|
||||||
|
},
|
||||||
|
action_steps: [
|
||||||
|
{ action_type: 'fill_application_fields' },
|
||||||
|
{ action_type: 'build_application_preview' },
|
||||||
|
{ action_type: 'validate_required_fields' },
|
||||||
|
{ action_type: 'save_application_draft' }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}, {
|
||||||
|
prompt: '2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(plan.steps, [
|
||||||
|
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||||
|
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||||
|
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI intent planner falls back to rule plan for compact travel direct submit', () => {
|
||||||
|
const plan = buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
|
||||||
|
|
||||||
|
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
|
||||||
|
assert.equal(plan.intent, 'create_travel_application')
|
||||||
|
assert.equal(plan.requestedAction, 'submit')
|
||||||
|
assert.deepEqual(plan.steps, [
|
||||||
|
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||||
|
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||||
|
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
|
||||||
|
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI intent planner detects compact travel save-draft variant before rules are enough', () => {
|
||||||
|
const prompt = '2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。'
|
||||||
|
const plan = buildRuleFallbackWorkbenchAiIntentPlan(prompt)
|
||||||
|
|
||||||
|
assert.equal(shouldRequestWorkbenchAiIntentPlan(prompt), true)
|
||||||
|
assert.equal(shouldRequestWorkbenchAiIntentPlan('帮我查询上海差旅标准'), true)
|
||||||
|
assert.equal(shouldRequestWorkbenchAiIntentPlan('1'), false)
|
||||||
|
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
|
||||||
|
assert.equal(plan.intent, 'create_travel_application')
|
||||||
|
assert.equal(plan.requestedAction, 'save_draft')
|
||||||
|
assert.deepEqual(plan.steps, [
|
||||||
|
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||||
|
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||||
|
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
|
||||||
|
])
|
||||||
|
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: '差旅费',
|
||||||
|
sourceText: prompt,
|
||||||
|
ontologyFields: {},
|
||||||
|
autoSubmit: false,
|
||||||
|
autoSaveDraft: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI intent planner turns model fields and action into executable application preview payload', () => {
|
||||||
|
const prompt = '2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。'
|
||||||
|
const plan = normalizeWorkbenchAiIntentPlan({
|
||||||
|
planning_source: 'llm_function_call',
|
||||||
|
tasks: [{
|
||||||
|
task_type: 'expense_application',
|
||||||
|
assigned_agent: 'application_assistant',
|
||||||
|
requested_action: 'save_draft',
|
||||||
|
confidence: 0.95,
|
||||||
|
ontology_fields: {
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '国网仿生产服务器部署',
|
||||||
|
transport_mode: '火车'
|
||||||
|
},
|
||||||
|
missing_fields: []
|
||||||
|
}]
|
||||||
|
}, { prompt })
|
||||||
|
|
||||||
|
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: '差旅费',
|
||||||
|
sourceText: prompt,
|
||||||
|
ontologyFields: {
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '国网仿生产服务器部署',
|
||||||
|
transport_mode: '火车'
|
||||||
|
},
|
||||||
|
autoSubmit: false,
|
||||||
|
autoSaveDraft: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI intent planner turns single application candidate flow into executable preview payload', () => {
|
||||||
|
const prompt = '2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车'
|
||||||
|
const plan = normalizeWorkbenchAiIntentPlan({
|
||||||
|
planning_source: 'rule_fallback',
|
||||||
|
plan_status: 'needs_flow_confirmation',
|
||||||
|
pending_flow_confirmation: {
|
||||||
|
status: 'pending',
|
||||||
|
candidate_flows: [{
|
||||||
|
flow_id: 'travel_application',
|
||||||
|
label: '先发起出差申请',
|
||||||
|
confidence: 0.86,
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'travel',
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
transport_mode: '火车'
|
||||||
|
},
|
||||||
|
missing_fields: []
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}, { prompt })
|
||||||
|
|
||||||
|
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
|
||||||
|
assert.equal(plan.intent, 'create_travel_application')
|
||||||
|
assert.equal(plan.requestedAction, 'preview')
|
||||||
|
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: '差旅费',
|
||||||
|
sourceText: prompt,
|
||||||
|
ontologyFields: {
|
||||||
|
expense_type: 'travel',
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
transport_mode: '火车'
|
||||||
|
},
|
||||||
|
autoSubmit: false,
|
||||||
|
autoSaveDraft: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI application preview prefers model ontology fields over local text guesses', () => {
|
||||||
|
const preview = buildInlineApplicationPreview(
|
||||||
|
'差旅费',
|
||||||
|
'2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。',
|
||||||
|
{ name: '李文静', grade: 'P5', location: '武汉' },
|
||||||
|
{
|
||||||
|
ontologyFields: {
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海',
|
||||||
|
reason: '国网仿生产服务器部署',
|
||||||
|
transport_mode: '火车'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||||
|
assert.equal(preview.fields.location, '上海')
|
||||||
|
assert.equal(preview.fields.reason, '国网仿生产服务器部署')
|
||||||
|
assert.equal(preview.fields.transportMode, '火车')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI intent planner rejects policy question and resolves executable application request', () => {
|
||||||
|
assert.equal(buildRuleFallbackWorkbenchAiIntentPlan('帮我查询上海差旅标准'), null)
|
||||||
|
|
||||||
|
const request = resolveExecutableTravelApplicationPlan(
|
||||||
|
buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
expenseType: 'travel',
|
||||||
|
expenseTypeLabel: '差旅费',
|
||||||
|
sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
|
||||||
|
ontologyFields: {},
|
||||||
|
autoSubmit: true,
|
||||||
|
autoSaveDraft: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI mode asks steward model plan before fallback execution', () => {
|
||||||
|
assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
|
||||||
|
assert.match(stewardFlowScript, /fetchStewardPlan\(planRequest/)
|
||||||
|
assert.match(stewardFlowScript, /timeoutMs:\s*35000/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan\(modelPlan,\s*\{\s*prompt:\s*cleanPrompt/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /shouldRequestWorkbenchAiIntentPlan\(cleanPrompt\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan\(intentPlan\)/)
|
||||||
|
assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /autoSaveDraft:\s*travelApplicationRequest\.autoSaveDraft/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /buildInlineApplicationPreview\([\s\S]*ontologyFields:\s*options\.ontologyFields/)
|
||||||
|
assert.doesNotMatch(personalWorkbenchAiModeScript, /const travelApplicationRequest = resolveInlineTravelApplicationRequest\(cleanPrompt\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI mode shows a visible planning response before waiting for steward model plan', () => {
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /function startModelPlanningConversation\(cleanPrompt, entry = \{\}\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /正在识别意图,准备拆解申请、报销和附件任务/)
|
||||||
|
assert.match(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/const plannerPendingMessage = startModelPlanningConversation\(cleanPrompt, entry\)[\s\S]*await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/pendingMessageId:\s*plannerPendingMessage\?\.id/
|
||||||
|
)
|
||||||
|
assert.match(applicationPreviewFlowScript, /options\.pendingMessageId/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI mode streams planning thinking into the pending message', () => {
|
||||||
|
assert.match(planningThinkingModelScript, /buildModelPlanningProgressSchedule/)
|
||||||
|
assert.match(planningThinkingModelScript, /判断办理意图/)
|
||||||
|
assert.match(planningThinkingModelScript, /抽取关键信息/)
|
||||||
|
assert.match(planningThinkingModelScript, /规划执行步骤/)
|
||||||
|
assert.match(planningThinkingModelScript, /准备兜底策略/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /function startModelPlanningProgressUpdates\(messageId\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /globalThis\.setTimeout\(\(\) => \{\s*updateModelPlanningThinkingEvent\(messageId, event\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /const stopPlanningProgressUpdates = startModelPlanningProgressUpdates\(plannerPendingMessage\.id\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /stopPlanningProgressUpdates\(\)/)
|
||||||
|
assert.match(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{\s*pendingMessageId:\s*plannerPendingMessage\.id\s*\}\)/
|
||||||
|
)
|
||||||
|
assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
|
||||||
|
assert.match(stewardFlowScript, /fetchInlineStewardPlan\(planningMessageId, planRequest,\s*\{[\s\S]*includeAnswerDelta:\s*false/)
|
||||||
|
assert.match(applicationPreviewFlowScript, /mergeWorkbenchAiThinkingEvents\(previousThinkingEvents,\s*\[/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench AI mode reuses planning pending message for regular steward replies', () => {
|
||||||
|
assert.match(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/stewardFlow\.requestInlineAssistantReply\(cleanPrompt, entry, files,\s*\{\s*pendingMessageId:\s*plannerPendingMessage\.id\s*\}\)/
|
||||||
|
)
|
||||||
|
assert.doesNotMatch(
|
||||||
|
personalWorkbenchAiModeScript,
|
||||||
|
/replaceInlineMessage\(plannerPendingMessage\.id,\s*createInlineMessage\('assistant', '已完成意图识别,继续为您整理回复。'/
|
||||||
|
)
|
||||||
|
assert.match(stewardFlowScript, /async function requestInlineAssistantReply\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
|
||||||
|
assert.match(stewardFlowScript, /const reusablePendingMessageId = String\(options\.pendingMessageId \|\| ''\)\.trim\(\)/)
|
||||||
|
assert.match(stewardFlowScript, /reusablePendingMessageId \? replaceInlineMessage\(reusablePendingMessageId, pendingMessage\) : conversationMessages\.value\.push\(pendingMessage\)/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user