feat(ai): add personal expense application memory
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
.application-memory-stack {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.application-memory-panel,
|
||||
.application-learning-receipt-panel {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--theme-primary-soft, #eaf4fa) 52%, #ffffff);
|
||||
}
|
||||
|
||||
.application-memory-panel-head,
|
||||
.application-learning-receipt-panel > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.application-memory-panel-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.application-memory-panel-head > span:last-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.application-memory-panel-head strong,
|
||||
.application-learning-receipt-panel > header strong {
|
||||
color: #17324a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.application-memory-panel-head small {
|
||||
color: #52677a;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.application-memory-list {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.application-memory-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #d7e4f2;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.application-memory-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.application-memory-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-memory-copy > strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.application-memory-copy > small,
|
||||
.application-memory-copy > em {
|
||||
color: #52677a;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.application-memory-copy > em {
|
||||
color: #708196;
|
||||
}
|
||||
|
||||
.application-memory-forget-btn {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(185, 28, 28, 0.24);
|
||||
border-radius: 6px;
|
||||
background: #fffafa;
|
||||
color: #991b1b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease, border-color 180ms ease, color 180ms ease;
|
||||
}
|
||||
|
||||
.application-memory-forget-btn:hover:not(:disabled),
|
||||
.application-memory-forget-btn:focus-visible {
|
||||
border-color: rgba(185, 28, 28, 0.48);
|
||||
background: #fef2f2;
|
||||
color: #7f1d1d;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.application-memory-forget-btn:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.14);
|
||||
}
|
||||
|
||||
.application-memory-forget-btn:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.application-learning-receipt-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.application-learning-receipt-panel > header {
|
||||
align-items: center;
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
}
|
||||
|
||||
.application-learning-receipt-panel > header i {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.application-learning-receipt {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #d6a12a;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.application-learning-receipt.is-applied {
|
||||
border-left-color: var(--theme-primary, #3a7ca5);
|
||||
}
|
||||
|
||||
.application-learning-receipt > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 5px 8px;
|
||||
}
|
||||
|
||||
.application-learning-receipt strong {
|
||||
color: #17324a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-learning-receipt span,
|
||||
.application-learning-receipt small {
|
||||
color: #52677a;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.application-memory-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.application-memory-forget-btn {
|
||||
justify-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.application-memory-forget-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
94
web/src/components/travel/TravelReimbursementMemoryPanel.vue
Normal file
94
web/src/components/travel/TravelReimbursementMemoryPanel.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div v-if="appliedMemories.length || learningReceipts.length" class="application-memory-stack">
|
||||
<section
|
||||
v-if="appliedMemories.length"
|
||||
class="application-memory-panel"
|
||||
aria-label="本次申请使用的历史偏好"
|
||||
>
|
||||
<header class="application-memory-panel-head">
|
||||
<span class="application-memory-panel-icon" aria-hidden="true">
|
||||
<i class="mdi mdi-history"></i>
|
||||
</span>
|
||||
<span>
|
||||
<strong>个性化填充</strong>
|
||||
<small>以下内容来自已确认的历史偏好,您仍可直接修改上方字段。</small>
|
||||
</span>
|
||||
</header>
|
||||
<div class="application-memory-list">
|
||||
<article
|
||||
v-for="memory in appliedMemories"
|
||||
:key="`${message.id}-memory-${memory.memoryId}`"
|
||||
class="application-memory-item"
|
||||
>
|
||||
<div class="application-memory-copy">
|
||||
<span class="application-memory-status">
|
||||
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
|
||||
已按历史偏好填入
|
||||
</span>
|
||||
<strong>{{ memory.fieldLabel }}:{{ memory.value }}</strong>
|
||||
<small v-if="memory.message">{{ memory.message }}</small>
|
||||
<em>{{ ui.resolveApplicationMemoryEvidenceText(memory) }}</em>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="application-memory-forget-btn"
|
||||
:disabled="ui.isForgettingApplicationMemory(memory)"
|
||||
:aria-busy="ui.isForgettingApplicationMemory(memory)"
|
||||
@click.stop="ui.forgetApplicationPreviewMemory(message, memory)"
|
||||
>
|
||||
<i
|
||||
:class="ui.isForgettingApplicationMemory(memory) ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-delete-outline'"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span>{{ ui.isForgettingApplicationMemory(memory) ? '正在忘记' : '忘记此偏好' }}</span>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="learningReceipts.length"
|
||||
class="application-learning-receipt-panel"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="本次申请的个性化学习结果"
|
||||
>
|
||||
<header>
|
||||
<i class="mdi mdi-lightbulb-on-outline" aria-hidden="true"></i>
|
||||
<strong>本次选择已记录</strong>
|
||||
</header>
|
||||
<article
|
||||
v-for="receipt in learningReceipts"
|
||||
:key="`${message.id}-learning-${receipt.memoryId}-${receipt.fieldKey}`"
|
||||
class="application-learning-receipt"
|
||||
:class="`is-${receipt.status}`"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ ui.resolveApplicationLearningReceiptTitle(receipt) }}</strong>
|
||||
<span>{{ receipt.fieldLabel }}:{{ receipt.value }}</span>
|
||||
</div>
|
||||
<small>{{ receipt.message || ui.resolveApplicationMemoryEvidenceText(receipt) }}</small>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
ui: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const appliedMemories = computed(() => props.ui.resolveAppliedApplicationPreviewMemories(props.message))
|
||||
const learningReceipts = computed(() => props.ui.resolveVisibleApplicationLearningReceipts(props.message))
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/travel-reimbursement-message-memory.css"></style>
|
||||
@@ -225,6 +225,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TravelReimbursementMemoryPanel :message="message" :ui="ui" />
|
||||
|
||||
<div
|
||||
v-if="ui.resolveApplicationPreviewMissingFields(message)?.length"
|
||||
class="application-preview-footer application-preview-footer-missing"
|
||||
@@ -686,12 +688,14 @@
|
||||
<script>
|
||||
import BudgetAssistantReport from './BudgetAssistantReport.vue'
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
import TravelReimbursementMemoryPanel from './TravelReimbursementMemoryPanel.vue'
|
||||
|
||||
export default {
|
||||
name: 'TravelReimbursementMessageItem',
|
||||
components: {
|
||||
BudgetAssistantReport,
|
||||
EnterpriseSelect
|
||||
EnterpriseSelect,
|
||||
TravelReimbursementMemoryPanel
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
|
||||
@@ -7,6 +7,62 @@ import {
|
||||
|
||||
export const AI_APPLICATION_ACTION_SAVE_DRAFT = 'ai_application_save_draft'
|
||||
export const AI_APPLICATION_ACTION_SUBMIT = 'ai_application_submit'
|
||||
const AI_APPLICATION_MEMORY_FIELD_LABELS = {
|
||||
amount: '预计金额',
|
||||
application_type: '申请类型',
|
||||
applicationType: '申请类型',
|
||||
days: '出差天数',
|
||||
location: '出差地点',
|
||||
reason: '申请事由',
|
||||
time: '出差时间',
|
||||
time_range: '出差时间',
|
||||
transport_mode: '出行方式',
|
||||
transportMode: '出行方式'
|
||||
}
|
||||
|
||||
export function normalizeAiApplicationMemory(item = {}) {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const memoryId = normalizeText(item.memoryId || item.memory_id)
|
||||
const fieldKey = normalizeText(item.fieldKey || item.field_key)
|
||||
const rawStatus = normalizeText(item.status).toLowerCase()
|
||||
const status = rawStatus === 'active' ? 'applied' : rawStatus
|
||||
if (!memoryId || !fieldKey || !status) return null
|
||||
return {
|
||||
memoryId,
|
||||
fieldKey,
|
||||
fieldLabel:
|
||||
normalizeText(item.fieldLabel || item.field_label)
|
||||
|| AI_APPLICATION_MEMORY_FIELD_LABELS[fieldKey]
|
||||
|| fieldKey,
|
||||
value: normalizeText(item.value),
|
||||
status,
|
||||
evidenceCount: Number(item.evidenceCount ?? item.evidence_count ?? 0) || 0,
|
||||
approvedEvidenceCount:
|
||||
Number(item.approvedEvidenceCount ?? item.approved_evidence_count ?? 0) || 0,
|
||||
message: normalizeText(item.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAiApplicationMemories(items = []) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.map((item) => normalizeAiApplicationMemory(item))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function resolveAiApplicationPreviewMemories(preview = {}) {
|
||||
const items = Array.isArray(preview?.memoryApplications)
|
||||
? preview.memoryApplications
|
||||
: preview?.memory_applications
|
||||
return normalizeAiApplicationMemories(items)
|
||||
}
|
||||
|
||||
export function resolveAiApplicationLearningReceipts(payload = {}) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : payload
|
||||
const items = Array.isArray(result?.learning_receipts)
|
||||
? result.learning_receipts
|
||||
: result?.learningReceipts
|
||||
return normalizeAiApplicationMemories(items)
|
||||
}
|
||||
|
||||
export function createAiApplicationRequestId(prefix = 'application') {
|
||||
const normalizedPrefix = normalizeText(prefix) || 'application'
|
||||
@@ -42,6 +98,13 @@ export async function registerAiApplicationPreviewDecision({
|
||||
const serverPreview = payload?.application_preview && typeof payload.application_preview === 'object'
|
||||
? payload.application_preview
|
||||
: {}
|
||||
const memoryApplications = resolveAiApplicationPreviewMemories({
|
||||
memoryApplications:
|
||||
serverPreview.memoryApplications
|
||||
?? serverPreview.memory_applications
|
||||
?? normalizedPreview.memoryApplications
|
||||
?? normalizedPreview.memory_applications
|
||||
})
|
||||
return normalizeApplicationPreview({
|
||||
...normalizedPreview,
|
||||
...serverPreview,
|
||||
@@ -52,6 +115,7 @@ export async function registerAiApplicationPreviewDecision({
|
||||
decisionId: normalizeText(payload?.decision_id || serverPreview.decisionId),
|
||||
decisionSource: normalizeText(payload?.decision_source || serverPreview.decisionSource),
|
||||
decisionExpiresAt: normalizeText(payload?.expires_at || serverPreview.decisionExpiresAt),
|
||||
memoryApplications,
|
||||
decisionTrackingStatus: 'registered'
|
||||
})
|
||||
}
|
||||
@@ -199,3 +263,14 @@ export function runAiApplicationPreviewAction(params = {}, options = {}) {
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export function forgetAiApplicationMemory(memoryId, options = {}) {
|
||||
const normalizedMemoryId = normalizeText(memoryId)
|
||||
if (!normalizedMemoryId) {
|
||||
return Promise.reject(new Error('缺少要忘记的偏好标识。'))
|
||||
}
|
||||
return apiRequest(`/expense-application-memories/${encodeURIComponent(normalizedMemoryId)}`, {
|
||||
method: 'DELETE',
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,7 +243,13 @@ export default {
|
||||
canRetryApplicationPreviewDecision,
|
||||
canSaveApplicationPreviewDraft,
|
||||
canSubmitApplicationPreview,
|
||||
forgetApplicationPreviewMemory,
|
||||
isForgettingApplicationMemory,
|
||||
registerApplicationPreviewDecision,
|
||||
resolveApplicationLearningReceiptTitle,
|
||||
resolveApplicationMemoryEvidenceText,
|
||||
resolveAppliedApplicationPreviewMemories,
|
||||
resolveVisibleApplicationLearningReceipts,
|
||||
retryApplicationPreviewDecision,
|
||||
runApplicationPreviewAction,
|
||||
saveApplicationPreviewDraft
|
||||
@@ -590,7 +596,7 @@ export default {
|
||||
submitComposerFromMessageHandlers = submitComposerMessageHandler
|
||||
|
||||
const { insightPanelUi, messageItemUi } = useTravelReimbursementCreateViewUi({
|
||||
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
|
||||
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, forgetApplicationPreviewMemory, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isForgettingApplicationMemory, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationLearningReceiptTitle, resolveApplicationMemoryEvidenceText, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveAppliedApplicationPreviewMemories, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, resolveVisibleApplicationLearningReceipts, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
riskFlags: [],
|
||||
pendingAttachmentAssociation: null,
|
||||
applicationPreview: null,
|
||||
applicationLearningReceipts: [],
|
||||
budgetReport: null,
|
||||
stewardPlan: null,
|
||||
operationFeedback: null,
|
||||
|
||||
@@ -122,7 +122,11 @@ export function normalizeInitialConversationMessages(conversation) {
|
||||
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
|
||||
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
|
||||
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [],
|
||||
applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null
|
||||
applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null,
|
||||
applicationLearningReceipts:
|
||||
item.role === 'assistant' && Array.isArray(result?.learning_receipts)
|
||||
? result.learning_receipts
|
||||
: []
|
||||
})
|
||||
})
|
||||
return markResolvedSuggestedActionMessages(restoredMessages)
|
||||
@@ -176,6 +180,9 @@ export function serializeSessionMessages(messages) {
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
applicationLearningReceipts: Array.isArray(message.applicationLearningReceipts)
|
||||
? message.applicationLearningReceipts
|
||||
: [],
|
||||
pendingApplicationPreviewDecision: message.pendingApplicationPreviewDecision || null,
|
||||
applicationSubmitConfirmed: Boolean(message.applicationSubmitConfirmed),
|
||||
budgetReport: message.budgetReport || null,
|
||||
@@ -203,6 +210,7 @@ export function hasMeaningfulSessionMessages(messages) {
|
||||
|| message.queryPayload
|
||||
|| message.draftPayload
|
||||
|| message.applicationPreview
|
||||
|| (Array.isArray(message.applicationLearningReceipts) && message.applicationLearningReceipts.length)
|
||||
|| message.budgetReport
|
||||
|| message.stewardPlan
|
||||
|| message.operationFeedback
|
||||
|
||||
@@ -279,7 +279,9 @@ export function createStewardDelegationHelpers({
|
||||
}
|
||||
|
||||
function shouldPauseStewardApplicationPreview(preview = {}) {
|
||||
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
|
||||
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(preview)
|
||||
// 仅缺出行方式时先交给认证签发端尝试应用个人记忆;其他缺失仍由 Steward 追问。
|
||||
return missingFields.some((field) => String(field || '').trim() !== '出行方式')
|
||||
}
|
||||
|
||||
function extractStewardCarryLine(text = '', label = '') {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
createAiApplicationRequestId,
|
||||
forgetAiApplicationMemory,
|
||||
normalizeAiApplicationMemory,
|
||||
registerAiApplicationPreviewDecision,
|
||||
resolveAiApplicationLearningReceipts,
|
||||
resolveAiApplicationPreviewMemories,
|
||||
runAiApplicationPreviewAction
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
|
||||
@@ -11,6 +17,14 @@ const DECISION_STATUS_REGISTERED = 'registered'
|
||||
const DECISION_STATUS_REGISTERING = 'registering'
|
||||
const DECISION_STATUS_FAILED = 'registration_failed'
|
||||
const DECISION_STATUS_UNREGISTERED = 'unregistered'
|
||||
const VISIBLE_LEARNING_RECEIPT_STATUSES = new Set(['candidate', 'applied'])
|
||||
const SERVER_RESOLVABLE_MISSING_FIELDS = new Set(['出行方式'])
|
||||
const MEMORY_FIELD_ALIASES = {
|
||||
application_type: 'applicationType',
|
||||
manager_name: 'managerName',
|
||||
time_range: 'time',
|
||||
transport_mode: 'transportMode'
|
||||
}
|
||||
|
||||
function resolveRefValue(value, fallback = null) {
|
||||
if (value && typeof value === 'object' && 'value' in value) {
|
||||
@@ -36,6 +50,18 @@ function resolveDecisionSourceText(preview = {}, sourceText = '') {
|
||||
)
|
||||
}
|
||||
|
||||
function canResolveApplicationPreviewOnServer(preview = {}) {
|
||||
const missingFields = Array.isArray(preview?.missingFields) ? preview.missingFields : []
|
||||
const validationIssues = Array.isArray(preview?.validationIssues)
|
||||
? preview.validationIssues
|
||||
: []
|
||||
return Boolean(
|
||||
!validationIssues.length
|
||||
&& missingFields.length
|
||||
&& missingFields.every((field) => SERVER_RESOLVABLE_MISSING_FIELDS.has(normalizeText(field)))
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplicationDraftPayload(message = {}, draftClaimId = '', linkedRequest = null) {
|
||||
if (message?.draftPayload && typeof message.draftPayload === 'object') {
|
||||
return message.draftPayload
|
||||
@@ -59,6 +85,7 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
draftClaimId,
|
||||
emitDraftSaved,
|
||||
entrySource = 'application',
|
||||
confirmForgetMemory = null,
|
||||
linkedRequest,
|
||||
persistSessionState = () => {},
|
||||
reviewActionBusy,
|
||||
@@ -66,6 +93,97 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
submitting,
|
||||
toast = () => {}
|
||||
} = {}) {
|
||||
const forgettingApplicationMemoryIds = ref(new Set())
|
||||
|
||||
function resolveApplicationMemoryFieldKey(fieldKey = '') {
|
||||
const normalized = normalizeText(fieldKey)
|
||||
return MEMORY_FIELD_ALIASES[normalized] || normalized
|
||||
}
|
||||
|
||||
function resolveAppliedApplicationPreviewMemories(message) {
|
||||
const preview = normalizeApplicationPreview(message?.applicationPreview || {})
|
||||
return resolveAiApplicationPreviewMemories(preview).filter((memory) => {
|
||||
if (memory.status !== 'applied') return false
|
||||
const fieldKey = resolveApplicationMemoryFieldKey(memory.fieldKey)
|
||||
return normalizeText(preview.fields?.[fieldKey]) === normalizeText(memory.value)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveVisibleApplicationLearningReceipts(message) {
|
||||
return (Array.isArray(message?.applicationLearningReceipts)
|
||||
? message.applicationLearningReceipts
|
||||
: [])
|
||||
.map((item) => normalizeAiApplicationMemory(item))
|
||||
.filter((item) => item && VISIBLE_LEARNING_RECEIPT_STATUSES.has(item.status))
|
||||
}
|
||||
|
||||
function resolveApplicationMemoryEvidenceText(memory = {}) {
|
||||
const approvedCount = Number(memory.approvedEvidenceCount || 0)
|
||||
const evidenceCount = Number(memory.evidenceCount || 0)
|
||||
if (approvedCount > 0) {
|
||||
return `${approvedCount} 次已审批记录支持${evidenceCount > approvedCount ? `,共 ${evidenceCount} 次证据` : ''}`
|
||||
}
|
||||
return evidenceCount > 0 ? `${evidenceCount} 次历史记录支持` : '来自已确认的历史记录'
|
||||
}
|
||||
|
||||
function resolveApplicationLearningReceiptTitle(receipt = {}) {
|
||||
return receipt.status === 'applied' ? '偏好已激活' : '已记录为候选偏好'
|
||||
}
|
||||
|
||||
function isForgettingApplicationMemory(memory = {}) {
|
||||
const memoryId = normalizeText(memory.memoryId || memory.memory_id)
|
||||
return Boolean(memoryId && forgettingApplicationMemoryIds.value.has(memoryId))
|
||||
}
|
||||
|
||||
async function requestForgetMemoryConfirmation(memory) {
|
||||
const label = normalizeText(memory?.fieldLabel || memory?.field_label || memory?.fieldKey)
|
||||
if (typeof confirmForgetMemory === 'function') {
|
||||
return Boolean(await confirmForgetMemory(memory))
|
||||
}
|
||||
if (typeof globalThis.confirm === 'function') {
|
||||
return globalThis.confirm(
|
||||
`忘记${label ? `“${label}”` : '此项'}偏好?当前申请中的内容会保留,后续申请不再自动套用。`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function forgetApplicationPreviewMemory(message, memory = {}) {
|
||||
const normalizedMemory = normalizeAiApplicationMemory(memory)
|
||||
if (!message?.applicationPreview || !normalizedMemory) return false
|
||||
if (isForgettingApplicationMemory(normalizedMemory)) return false
|
||||
if (!(await requestForgetMemoryConfirmation(normalizedMemory))) return false
|
||||
|
||||
forgettingApplicationMemoryIds.value = new Set([
|
||||
...forgettingApplicationMemoryIds.value,
|
||||
normalizedMemory.memoryId
|
||||
])
|
||||
try {
|
||||
await forgetAiApplicationMemory(normalizedMemory.memoryId)
|
||||
const currentPreview = message.applicationPreview
|
||||
const nextMemories = resolveAiApplicationPreviewMemories(currentPreview).filter(
|
||||
(item) => item.memoryId !== normalizedMemory.memoryId
|
||||
)
|
||||
message.applicationPreview = {
|
||||
...currentPreview,
|
||||
memoryApplications: nextMemories
|
||||
}
|
||||
delete message.applicationPreview.memory_applications
|
||||
message.applicationLearningReceipts = resolveVisibleApplicationLearningReceipts(message)
|
||||
.filter((item) => item.memoryId !== normalizedMemory.memoryId)
|
||||
persistSessionState()
|
||||
toast('已忘记此偏好,当前申请内容保持不变。')
|
||||
return true
|
||||
} catch (error) {
|
||||
toast(resolveErrorMessage(error, '偏好撤销失败,请稍后重试。'))
|
||||
return false
|
||||
} finally {
|
||||
const nextBusyIds = new Set(forgettingApplicationMemoryIds.value)
|
||||
nextBusyIds.delete(normalizedMemory.memoryId)
|
||||
forgettingApplicationMemoryIds.value = nextBusyIds
|
||||
}
|
||||
}
|
||||
|
||||
function isInteractionBusy() {
|
||||
return Boolean(
|
||||
resolveRefValue(submitting, false)
|
||||
@@ -127,7 +245,7 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
preserveCurrentFields = false
|
||||
} = {}) {
|
||||
const normalized = normalizeApplicationPreview(applicationPreview || {})
|
||||
if (!normalized.readyToSubmit) {
|
||||
if (!normalized.readyToSubmit && !canResolveApplicationPreviewOnServer(normalized)) {
|
||||
return normalizeApplicationPreview({
|
||||
...normalized,
|
||||
decisionId: '',
|
||||
@@ -271,6 +389,7 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
throw error
|
||||
}
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
message.applicationLearningReceipts = resolveAiApplicationLearningReceipts(payload)
|
||||
const nextDraftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: null
|
||||
@@ -341,7 +460,13 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
canRetryApplicationPreviewDecision,
|
||||
canSaveApplicationPreviewDraft,
|
||||
canSubmitApplicationPreview,
|
||||
forgetApplicationPreviewMemory,
|
||||
isForgettingApplicationMemory,
|
||||
registerApplicationPreviewDecision,
|
||||
resolveApplicationLearningReceiptTitle,
|
||||
resolveApplicationMemoryEvidenceText,
|
||||
resolveAppliedApplicationPreviewMemories,
|
||||
resolveVisibleApplicationLearningReceipts,
|
||||
retryApplicationPreviewDecision,
|
||||
runApplicationPreviewAction,
|
||||
saveApplicationPreviewDraft
|
||||
|
||||
@@ -32,6 +32,12 @@ export function useTravelReimbursementCreateViewUi(ctx) {
|
||||
canSubmitApplicationPreview: ctx.canSubmitApplicationPreview,
|
||||
retryApplicationPreviewDecision: ctx.retryApplicationPreviewDecision,
|
||||
saveApplicationPreviewDraft: ctx.saveApplicationPreviewDraft,
|
||||
resolveAppliedApplicationPreviewMemories: ctx.resolveAppliedApplicationPreviewMemories,
|
||||
resolveVisibleApplicationLearningReceipts: ctx.resolveVisibleApplicationLearningReceipts,
|
||||
resolveApplicationLearningReceiptTitle: ctx.resolveApplicationLearningReceiptTitle,
|
||||
resolveApplicationMemoryEvidenceText: ctx.resolveApplicationMemoryEvidenceText,
|
||||
isForgettingApplicationMemory: ctx.isForgettingApplicationMemory,
|
||||
forgetApplicationPreviewMemory: ctx.forgetApplicationPreviewMemory,
|
||||
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
|
||||
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
|
||||
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,
|
||||
|
||||
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
forgetAiApplicationMemory,
|
||||
registerAiApplicationPreviewDecision,
|
||||
resolveAiApplicationLearningReceipts,
|
||||
runAiApplicationPreviewAction
|
||||
} from '../src/services/aiApplicationPreviewActions.js'
|
||||
|
||||
@@ -138,7 +140,16 @@ async function testRegistrationUsesServerCanonicalPreview() {
|
||||
fields: {
|
||||
reason: '服务端规范事由',
|
||||
amount: '1800元'
|
||||
}
|
||||
},
|
||||
memoryApplications: [{
|
||||
memoryId: 'memory-transport-1',
|
||||
fieldKey: 'transport_mode',
|
||||
value: '火车',
|
||||
status: 'active',
|
||||
evidenceCount: 4,
|
||||
approvedEvidenceCount: 3,
|
||||
message: '根据历史申请偏好填入'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +180,62 @@ async function testRegistrationUsesServerCanonicalPreview() {
|
||||
assert.equal(preview.fields.reason, '服务端规范事由')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.decisionTrackingStatus, 'registered')
|
||||
assert.deepEqual(preview.memoryApplications, [{
|
||||
memoryId: 'memory-transport-1',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'applied',
|
||||
evidenceCount: 4,
|
||||
approvedEvidenceCount: 3,
|
||||
message: '根据历史申请偏好填入'
|
||||
}])
|
||||
}
|
||||
|
||||
async function testLearningReceiptNormalizationAndForgetEndpoint() {
|
||||
const receipts = resolveAiApplicationLearningReceipts({
|
||||
result: {
|
||||
learning_receipts: [{
|
||||
memory_id: 'memory-reason-1',
|
||||
field_key: 'reason',
|
||||
field_label: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate',
|
||||
evidence_count: 2,
|
||||
approved_evidence_count: 1,
|
||||
message: '再确认一次后可形成稳定偏好'
|
||||
}]
|
||||
}
|
||||
})
|
||||
assert.deepEqual(receipts, [{
|
||||
memoryId: 'memory-reason-1',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate',
|
||||
evidenceCount: 2,
|
||||
approvedEvidenceCount: 1,
|
||||
message: '再确认一次后可形成稳定偏好'
|
||||
}])
|
||||
|
||||
let capturedUrl = ''
|
||||
let capturedOptions = null
|
||||
global.fetch = async (url, options) => {
|
||||
capturedUrl = String(url)
|
||||
capturedOptions = options
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { status: 'forgotten' }
|
||||
}
|
||||
}
|
||||
}
|
||||
await forgetAiApplicationMemory('memory/reason 1')
|
||||
assert.equal(
|
||||
capturedUrl,
|
||||
'/api/v1/expense-application-memories/memory%2Freason%201'
|
||||
)
|
||||
assert.equal(capturedOptions.method, 'DELETE')
|
||||
}
|
||||
|
||||
async function testEditDraftActionCarriesClaimAndEditableFields() {
|
||||
@@ -259,6 +326,7 @@ async function run() {
|
||||
await testSubmitActionUsesFastPreviewEndpoint()
|
||||
await testSaveDraftActionUsesFastPreviewEndpoint()
|
||||
await testRegistrationUsesServerCanonicalPreview()
|
||||
await testLearningReceiptNormalizationAndForgetEndpoint()
|
||||
await testEditDraftActionCarriesClaimAndEditableFields()
|
||||
await testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults()
|
||||
console.log('ai-application-preview-actions tests passed')
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from '../src/utils/assistantSessionScope.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
|
||||
import { resolveAssistantResultText } from '../src/views/scripts/travelReimbursementSubmitResponseModel.js'
|
||||
|
||||
const submitComposerScript = [
|
||||
'../src/views/scripts/travelReimbursementSubmitConstants.js',
|
||||
@@ -83,10 +84,12 @@ const stewardServiceScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/steward.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = [
|
||||
'../src/views/scripts/TravelReimbursementCreateView.js',
|
||||
'../src/views/scripts/useTravelReimbursementCreateViewState.js',
|
||||
'../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js',
|
||||
'../src/views/scripts/useTravelReimbursementApplicationPreviewDateEditor.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const messageActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementMessageActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -95,10 +98,11 @@ const suggestedActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardRuntimeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardRuntimeScript = [
|
||||
'../src/views/scripts/useTravelReimbursementStewardRuntime.js',
|
||||
'../src/views/scripts/useTravelReimbursementStewardRuntimeDecision.js',
|
||||
'../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const stewardRuntimeTextModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardRuntimeTextModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -131,18 +135,20 @@ const applicationMessageStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-application.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const conversationModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const conversationModelScript = [
|
||||
'../src/views/scripts/travelReimbursementConversationModel.js',
|
||||
'../src/views/scripts/travelReimbursementConversationMessageModel.js',
|
||||
'../src/views/scripts/travelReimbursementConversationStateModel.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const previewEditorScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const flowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const flowScript = [
|
||||
'../src/views/scripts/useTravelReimbursementFlow.js',
|
||||
'../src/views/scripts/travelReimbursementFlowToolModel.js',
|
||||
'../src/views/scripts/travelReimbursementFlowTiming.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const personalWorkbenchAiModeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -151,6 +157,10 @@ const applicationPreviewFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const applicationPreviewActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementApplicationPreviewActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function createFlowHarness() {
|
||||
return useTravelReimbursementFlow({
|
||||
@@ -288,7 +298,7 @@ test('unsupported business guidance opens in assistant conversation form', () =>
|
||||
assert.equal(conversation.messages.length, 1)
|
||||
assert.equal(conversation.messages[0].role, 'assistant')
|
||||
assert.match(conversation.messages[0].content, /小财管家暂时不处理「你好」/)
|
||||
assert.equal(conversation.messages[0].assistantName, '小财管家')
|
||||
assert.equal(conversation.messages[0].message_json.assistant_name, '小财管家')
|
||||
assert.match(conversation.messages[0].content, /### 当前可继续的场景/)
|
||||
assert.equal(
|
||||
conversation.messages[0].message_json.orchestrator_payload.result.suggested_actions.length,
|
||||
@@ -312,8 +322,8 @@ test('assistant scope guard blocks unsupported non-financial intent', () => {
|
||||
assert.equal(guard.suggestedActions.length, 4)
|
||||
assert.equal(guard.blocked, true)
|
||||
assert.equal(guard.targetSessionType, '')
|
||||
assert.match(guard.text, /此意图系统不支持/)
|
||||
assert.match(guard.text, /当前系统支持的业务范围/)
|
||||
assert.match(guard.text, /没有识别到当前系统支持的财务业务意图/)
|
||||
assert.match(guard.text, /当前可继续的场景/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1057,7 +1067,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(createViewScript, /const isApplicationSession = computed/)
|
||||
assert.match(createViewScript, /insightPanelCollapsed,/)
|
||||
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
|
||||
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
|
||||
assert.match(createViewScript, /getActiveFlowSteps\(\)\.length > 0/)
|
||||
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
||||
assert.match(messageActionsScript, /message-bubble-application-preview/)
|
||||
assert.match(messageActionsScript, /buildApplicationPreviewFooterMessage/)
|
||||
@@ -1078,7 +1088,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /application-preview-date-chip/)
|
||||
assert.match(messageItemTemplate, /'application-preview-date-chip': \['time', 'time_return'\]\.includes\(row\.key\) && !row\.missing/)
|
||||
assert.match(messageItemTemplate, /申请单据已生成/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
|
||||
assert.match(messageItemTemplate, /报销草稿已生成/)
|
||||
@@ -1130,7 +1140,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /commitApplicationPreviewEditor/)
|
||||
assert.match(createViewScript, /resolveApplicationPreviewMissingFields/)
|
||||
assert.match(createViewScript, /function applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /onComposerDateSelection: \(\.\.\.args\) => applyLinkedApplicationPreviewDateSelection\(\.\.\.args\)/)
|
||||
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
|
||||
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
|
||||
assert.match(messageActionsScript, /function shouldShowAssistantMessageActions/)
|
||||
@@ -1149,8 +1159,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(previewEditorScript, /getTodayDateValue/)
|
||||
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(previewEditorScript, /targetRow\.editable === false/)
|
||||
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
|
||||
assert.match(previewEditorScript, /fieldKey === 'time'\) return 'date'/)
|
||||
assert.match(previewEditorScript, /function buildEditedApplicationPreviewFields/)
|
||||
assert.match(previewEditorScript, /\[isDateField \? 'time' : editor\.fieldKey\]: nextValue/)
|
||||
assert.match(previewEditorScript, /isApplicationPreviewDateField\(fieldKey\)\) return 'date'/)
|
||||
assert.match(previewEditorScript, /commitApplicationPreviewDateEditor/)
|
||||
|
||||
assert.match(messageItemStyles, /@import "\.\/travel-reimbursement-message-application\.css";/)
|
||||
@@ -1198,7 +1209,7 @@ test('steward application missing transport blocks preview table', () => {
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
||||
assert.doesNotMatch(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
assert.match(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
|
||||
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
|
||||
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
|
||||
@@ -1208,7 +1219,14 @@ test('steward application missing transport blocks preview table', () => {
|
||||
assert.match(suggestedActionsScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
|
||||
assert.match(suggestedActionsScript, /commitApplicationPreviewEditor\(targetMessage\)/)
|
||||
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
|
||||
assert.match(stewardFieldCompletionScript, /基础规则交通费用预估表/)
|
||||
assert.match(stewardFieldCompletionScript, /模拟查询交通票据和费用口径/)
|
||||
})
|
||||
|
||||
test('steward lets server memory resolve transport but still blocks other missing fields', () => {
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/missingFields\.some\(\(field\) => String\(field \|\| ''\)\.trim\(\) !== '出行方式'\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('steward field completion reruns application preview instead of directly rendering table', () => {
|
||||
@@ -1253,7 +1271,7 @@ test('steward field completion reruns application preview instead of directly re
|
||||
assert.match(carryText, /用户已补充:出行方式:火车/)
|
||||
assert.match(carryText, /地点:北京/)
|
||||
assert.match(carryText, /天数:3天/)
|
||||
assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/)
|
||||
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据和费用口径/)
|
||||
|
||||
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
||||
assert.equal(rebuiltPreview.fields.location, '北京')
|
||||
@@ -1309,8 +1327,7 @@ test('text confirmation submits pending application preview before replanning st
|
||||
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(suggestedActionsScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
|
||||
assert.match(submitComposerScript, /const requireModelReview = shouldRequireApplicationModelReview\(rawText\)/)
|
||||
assert.match(submitComposerScript, /if \(options\.skipModelReview && !requireModelReview\) \{[\s\S]*结构化快路径/)
|
||||
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
|
||||
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
|
||||
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
|
||||
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
|
||||
@@ -1327,29 +1344,33 @@ test('text confirmation submits pending application preview before replanning st
|
||||
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
|
||||
assert.match(stewardRuntimeScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(stewardRuntimeScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
|
||||
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
|
||||
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) return null/)
|
||||
assert.match(stewardRuntimeTextModelScript, /function isApplicationSubmitConfirmationText/)
|
||||
assert.match(stewardRuntimeTextModelScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
|
||||
assert.match(stewardRuntimeScript, /function findPendingApplicationSubmitMessage/)
|
||||
assert.match(stewardRuntimeScript, /normalizedPreview\.readyToSubmit/)
|
||||
assert.match(stewardRuntimeScript, /async function handleApplicationSubmitConfirmationText/)
|
||||
assert.match(stewardRuntimeScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
|
||||
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
|
||||
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) return null[\s\S]*if \(await handleGuidedStewardPlan\(options\)\) return null/)
|
||||
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed = true/)
|
||||
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
|
||||
})
|
||||
|
||||
test('application submit result does not render reimbursement review followup', () => {
|
||||
assert.match(submitComposerScript, /function shouldExposeReviewPayloadForMessage\(payload, options = \{\}\)/)
|
||||
assert.match(submitComposerScript, /options\.isApplicationSubmitOperation \|\| isApplicationDraftPayload\(result\.draft_payload\)/)
|
||||
assert.match(submitComposerScript, /function buildPresentationPayload\(payload, \{ exposeReviewPayload = true \} = \{\}\)/)
|
||||
assert.match(submitComposerScript, /review_payload:\s*null/)
|
||||
assert.match(submitComposerScript, /const exposeReviewPayload = shouldExposeReviewPayloadForMessage\(payload, \{ isApplicationSubmitOperation \}\)/)
|
||||
assert.match(submitComposerScript, /const presentationPayload = buildPresentationPayload\(payload, \{ exposeReviewPayload \}\)/)
|
||||
assert.match(submitComposerScript, /const resultReviewPayload = presentationResult\.review_payload \|\| null/)
|
||||
assert.match(submitComposerScript, /suggestedActions:\s*resultSuggestedActions/)
|
||||
assert.match(submitComposerScript, /reviewPayload:\s*resultReviewPayload/)
|
||||
assert.match(submitComposerScript, /buildAgentInsight\(\s*presentationPayload,/)
|
||||
test('application submit result uses dedicated action without reimbursement review followup', () => {
|
||||
const submittedPayload = {
|
||||
result: {
|
||||
answer: '申请提交成功',
|
||||
draft_payload: {
|
||||
draft_type: 'expense_application',
|
||||
status: 'submitted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(resolveAssistantResultText(submittedPayload, 'fallback'), '')
|
||||
assert.match(stewardRuntimeScript, /applicationPreview[\s\S]*await runApplicationPreviewAction\([\s\S]*AI_APPLICATION_ACTION_SUBMIT/)
|
||||
assert.match(applicationPreviewActionsScript, /message\.draftPayload = nextDraftPayload/)
|
||||
assert.doesNotMatch(applicationPreviewActionsScript, /message\.reviewPayload\s*=/)
|
||||
})
|
||||
|
||||
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
|
||||
@@ -1371,18 +1392,19 @@ test('steward typewriter renders markdown table blocks at once', () => {
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, normalIndex), 3)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex), nextParagraphIndex)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex - 1), nextParagraphIndex)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 2)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 3)
|
||||
})
|
||||
|
||||
test('application preview table appears as a whole card instead of row-by-row animation', () => {
|
||||
assert.doesNotMatch(
|
||||
test('application preview rows use bounded staggered reveal with reduced-motion fallback', () => {
|
||||
assert.match(
|
||||
messageItemStyles,
|
||||
/structured-card-reveal-enter-active\s+\.application-preview-row\s*\{[\s\S]*animation:/,
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
messageItemStyles,
|
||||
/application-preview-row:nth-child\([^)]*\)\s*\{[\s\S]*animation-delay:/,
|
||||
/application-preview-row:nth-child\(n \+ 6\)\s*\{[\s\S]*animation-delay: 165ms;/,
|
||||
)
|
||||
assert.match(messageItemStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.structured-card-reveal-enter-active \.application-preview-row,[\s\S]*animation: none;/)
|
||||
})
|
||||
|
||||
test('complex travel application sentences require model review', () => {
|
||||
@@ -1437,7 +1459,7 @@ test('steward application carry text does not leak transport examples into extra
|
||||
assert.match(carryText, /费用类型:差旅/)
|
||||
assert.doesNotMatch(carryText, /费用类型:travel/)
|
||||
assert.match(carryText, /还需要补充:出行方式/)
|
||||
assert.doesNotMatch(carryText, /请先追问上述缺失信息/)
|
||||
assert.match(carryText, /请先追问上述缺失信息/)
|
||||
assert.doesNotMatch(carryText, /请直接生成申请单核对结果/)
|
||||
assert.doesNotMatch(carryText, /入库或提交审批前/)
|
||||
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
|
||||
@@ -1601,10 +1623,10 @@ test('assistant markdown tables render with component-scoped table styling', ()
|
||||
assert.match(rendered, /<th/)
|
||||
assert.match(rendered, /<td/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-table-wrap\) \{[\s\S]*overflow-x: auto;[\s\S]*border: 1px solid #dbe4ee;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 560px;[\s\S]*table-layout: fixed;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*overflow-wrap: break-word;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:first-child\),[\s\S]*\.message-answer-markdown :deep\(td:first-child\) \{[\s\S]*width: 88px;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:last-child\),[\s\S]*\.message-answer-markdown :deep\(td:last-child\) \{[\s\S]*width: 112px;[\s\S]*text-align: right;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 460px;[\s\S]*border-collapse: separate;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*white-space: normal;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(tbody tr:nth-child\(even\) td\) \{[\s\S]*background: #fbfdff;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(tbody tr:last-child td\) \{[\s\S]*border-bottom: 0;/)
|
||||
})
|
||||
|
||||
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
@@ -12,6 +14,19 @@ import {
|
||||
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
|
||||
import { useTravelReimbursementApplicationSubmitConfirm } from '../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js'
|
||||
|
||||
const messageItemTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const memoryPanelTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMemoryPanel.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewUiScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewUi.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function createPreview(overrides = {}) {
|
||||
return {
|
||||
fields: {
|
||||
@@ -100,6 +115,59 @@ test('完整 preview 展示前写入稳定签发/动作 request id,并使用 c
|
||||
assert.ok(persisted.length >= 2)
|
||||
})
|
||||
|
||||
test('仅缺出行方式时仍请求服务端,让 active memory 补齐并签发', async () => {
|
||||
const targetMessage = { id: 'preview-memory-resolution', applicationPreview: null }
|
||||
const { actions } = createActions()
|
||||
let requestCount = 0
|
||||
global.fetch = async (_url, options) => {
|
||||
requestCount += 1
|
||||
const body = JSON.parse(options.body)
|
||||
assert.match(body.message, /客户现场实施/)
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
decision_id: 'decision-memory-resolution',
|
||||
decision_source: 'hybrid',
|
||||
expires_at: '2026-07-14T10:30:00Z',
|
||||
application_preview: {
|
||||
fields: {
|
||||
...createPreview().fields,
|
||||
transportMode: '火车',
|
||||
amount: '2880元'
|
||||
},
|
||||
memoryApplications: [{
|
||||
memory_id: 'memory-transport-active',
|
||||
field_key: 'transport_mode',
|
||||
value: '火车',
|
||||
status: 'applied',
|
||||
evidence_count: 3,
|
||||
approved_evidence_count: 2
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preview = await actions.registerApplicationPreviewDecision({
|
||||
applicationPreview: createPreview({
|
||||
fields: {
|
||||
...createPreview().fields,
|
||||
transportMode: ''
|
||||
}
|
||||
}),
|
||||
sourceText: '7月20日去上海做客户现场实施',
|
||||
targetMessage
|
||||
})
|
||||
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.equal(preview.decisionId, 'decision-memory-resolution')
|
||||
assert.equal(preview.memoryApplications[0].memoryId, 'memory-transport-active')
|
||||
})
|
||||
|
||||
test('签发失败后 fail-closed,可编辑并以相同 request id 重试签发', async () => {
|
||||
const targetMessage = { id: 'preview-2', applicationPreview: null }
|
||||
const { actions } = createActions()
|
||||
@@ -186,7 +254,17 @@ test('动作失败重试复用 action request id;保存成功更新草稿和 n
|
||||
approval_stage: '待提交'
|
||||
},
|
||||
decision_id: 'decision-assistant-next',
|
||||
decision_expires_at: '2026-07-14T11:00:00Z'
|
||||
decision_expires_at: '2026-07-14T11:00:00Z',
|
||||
learning_receipts: [{
|
||||
memoryId: 'memory-reason-candidate',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate',
|
||||
evidenceCount: 2,
|
||||
approvedEvidenceCount: 1,
|
||||
message: '已记录本次修正,继续确认后可形成稳定偏好'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +275,7 @@ test('动作失败重试复用 action request id;保存成功更新草稿和 n
|
||||
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, message),
|
||||
/动作服务暂不可用/
|
||||
)
|
||||
assert.equal(message.applicationLearningReceipts, undefined)
|
||||
assert.equal(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
|
||||
await actions.saveApplicationPreviewDraft(message)
|
||||
|
||||
@@ -208,9 +287,172 @@ test('动作失败重试复用 action request id;保存成功更新草稿和 n
|
||||
assert.equal(state.draftClaimId.value, 'claim-assistant-draft')
|
||||
assert.equal(message.applicationPreview.decisionId, 'decision-assistant-next')
|
||||
assert.notEqual(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
|
||||
assert.equal(message.applicationLearningReceipts.length, 1)
|
||||
assert.equal(message.applicationLearningReceipts[0].status, 'candidate')
|
||||
assert.equal(emitted[0].status, 'draft')
|
||||
})
|
||||
|
||||
test('仅展示已实际填入当前字段的 applied memory,忘记后保留当前字段', async () => {
|
||||
const originalFields = createPreview().fields
|
||||
const message = {
|
||||
id: 'preview-memory',
|
||||
applicationPreview: createPreview({
|
||||
memoryApplications: [
|
||||
{
|
||||
memoryId: 'memory-transport-applied',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'applied',
|
||||
evidenceCount: 4,
|
||||
approvedEvidenceCount: 3,
|
||||
message: '根据已审批的历史差旅申请填入'
|
||||
},
|
||||
{
|
||||
memoryId: 'memory-reason-candidate',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate',
|
||||
evidenceCount: 2,
|
||||
approvedEvidenceCount: 1,
|
||||
message: '候选偏好'
|
||||
},
|
||||
{
|
||||
memoryId: 'memory-location-stale',
|
||||
fieldKey: 'location',
|
||||
fieldLabel: '出差地点',
|
||||
value: '北京',
|
||||
status: 'applied',
|
||||
evidenceCount: 3,
|
||||
approvedEvidenceCount: 2,
|
||||
message: '旧偏好值与当前字段不一致'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
let confirmCalls = 0
|
||||
let capturedUrl = ''
|
||||
const { actions, persisted, toasts } = createActions({
|
||||
confirmForgetMemory: async () => {
|
||||
confirmCalls += 1
|
||||
return true
|
||||
}
|
||||
})
|
||||
assert.deepEqual(
|
||||
actions.resolveAppliedApplicationPreviewMemories(message).map((item) => item.memoryId),
|
||||
['memory-transport-applied']
|
||||
)
|
||||
|
||||
global.fetch = async (url, options) => {
|
||||
capturedUrl = String(url)
|
||||
assert.equal(options.method, 'DELETE')
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { status: 'forgotten' }
|
||||
}
|
||||
}
|
||||
}
|
||||
const forgotten = await actions.forgetApplicationPreviewMemory(
|
||||
message,
|
||||
message.applicationPreview.memoryApplications[0]
|
||||
)
|
||||
assert.equal(forgotten, true)
|
||||
assert.equal(confirmCalls, 1)
|
||||
assert.equal(
|
||||
capturedUrl,
|
||||
'/api/v1/expense-application-memories/memory-transport-applied'
|
||||
)
|
||||
assert.deepEqual(message.applicationPreview.fields, originalFields)
|
||||
assert.deepEqual(
|
||||
message.applicationPreview.memoryApplications.map((item) => item.memoryId),
|
||||
['memory-reason-candidate', 'memory-location-stale']
|
||||
)
|
||||
assert.ok(persisted.length >= 1)
|
||||
assert.match(toasts.at(-1), /当前申请内容保持不变/)
|
||||
})
|
||||
|
||||
test('忘记偏好失败时不移除 memory,也不改当前字段', async () => {
|
||||
const message = {
|
||||
id: 'preview-memory-failure',
|
||||
applicationPreview: createPreview({
|
||||
memoryApplications: [{
|
||||
memoryId: 'memory-failure',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'applied'
|
||||
}]
|
||||
})
|
||||
}
|
||||
const previousPreview = structuredClone(message.applicationPreview)
|
||||
const { actions, toasts } = createActions({
|
||||
confirmForgetMemory: () => true
|
||||
})
|
||||
global.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
async json() {
|
||||
return { detail: '偏好服务暂不可用' }
|
||||
}
|
||||
})
|
||||
|
||||
const forgotten = await actions.forgetApplicationPreviewMemory(
|
||||
message,
|
||||
message.applicationPreview.memoryApplications[0]
|
||||
)
|
||||
assert.equal(forgotten, false)
|
||||
assert.deepEqual(message.applicationPreview, previousPreview)
|
||||
assert.match(toasts.at(-1), /偏好服务暂不可用/)
|
||||
})
|
||||
|
||||
test('申请预览 UI 解释已应用偏好并提供可访问的忘记入口', () => {
|
||||
assert.match(messageItemTemplate, /<TravelReimbursementMemoryPanel :message="message" :ui="ui" \/>/)
|
||||
assert.match(memoryPanelTemplate, /resolveAppliedApplicationPreviewMemories\(props\.message\)/)
|
||||
assert.match(memoryPanelTemplate, /已按历史偏好填入/)
|
||||
assert.match(memoryPanelTemplate, /您仍可直接修改上方字段/)
|
||||
assert.match(memoryPanelTemplate, /忘记此偏好/)
|
||||
assert.match(memoryPanelTemplate, /:aria-busy="ui\.isForgettingApplicationMemory\(memory\)"/)
|
||||
assert.match(memoryPanelTemplate, /resolveVisibleApplicationLearningReceipts\(props\.message\)/)
|
||||
assert.match(memoryPanelTemplate, /ui\.resolveApplicationLearningReceiptTitle\(receipt\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /已按历史偏好填入/)
|
||||
assert.match(createViewUiScript, /forgetApplicationPreviewMemory: ctx\.forgetApplicationPreviewMemory/)
|
||||
})
|
||||
|
||||
test('学习回执仅展示 candidate/applied,并兼容旧 active 状态', () => {
|
||||
const { actions } = createActions()
|
||||
const receipts = actions.resolveVisibleApplicationLearningReceipts({
|
||||
applicationLearningReceipts: [
|
||||
{
|
||||
memoryId: 'memory-candidate',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate'
|
||||
},
|
||||
{
|
||||
memoryId: 'memory-active-legacy',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
memoryId: 'memory-revoked',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '飞机',
|
||||
status: 'revoked'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(receipts.map((item) => item.status), ['candidate', 'applied'])
|
||||
assert.equal(actions.resolveApplicationLearningReceiptTitle(receipts[0]), '已记录为候选偏好')
|
||||
assert.equal(actions.resolveApplicationLearningReceiptTitle(receipts[1]), '偏好已激活')
|
||||
})
|
||||
|
||||
test('decision 过期后清空旧 ID 并开放重新签发', async () => {
|
||||
const message = {
|
||||
id: 'preview-expired',
|
||||
|
||||
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
normalizeInitialConversationMessages
|
||||
normalizeInitialConversationMessages,
|
||||
normalizeSnapshotMessage,
|
||||
serializeSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
|
||||
|
||||
test('Orchestrator 会话恢复 assistant 的 application preview,并保留既有结果字段', () => {
|
||||
@@ -12,7 +14,14 @@ test('Orchestrator 会话恢复 assistant 的 application preview,并保留既
|
||||
applicationType: '差旅费用申请',
|
||||
location: '上海',
|
||||
reason: '客户现场实施'
|
||||
}
|
||||
},
|
||||
memoryApplications: [{
|
||||
memoryId: 'memory-session-1',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'applied'
|
||||
}]
|
||||
}
|
||||
const [message] = normalizeInitialConversationMessages({
|
||||
messages: [{
|
||||
@@ -24,6 +33,13 @@ test('Orchestrator 会话恢复 assistant 的 application preview,并保留既
|
||||
orchestrator_payload: {
|
||||
result: {
|
||||
application_preview: applicationPreview,
|
||||
learning_receipts: [{
|
||||
memoryId: 'memory-session-2',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'candidate'
|
||||
}],
|
||||
draft_payload: { claim_id: 'claim-draft-1' },
|
||||
review_payload: { summary: '复核通过' },
|
||||
risk_flags: [{ code: 'policy-limit' }]
|
||||
@@ -34,11 +50,41 @@ test('Orchestrator 会话恢复 assistant 的 application preview,并保留既
|
||||
})
|
||||
|
||||
assert.deepEqual(message.applicationPreview, applicationPreview)
|
||||
assert.equal(message.applicationLearningReceipts[0].memoryId, 'memory-session-2')
|
||||
assert.deepEqual(message.draftPayload, { claim_id: 'claim-draft-1' })
|
||||
assert.deepEqual(message.reviewPayload, { summary: '复核通过' })
|
||||
assert.deepEqual(message.riskFlags, [{ code: 'policy-limit' }])
|
||||
})
|
||||
|
||||
test('本地快照跨刷新保留记忆应用和学习回执', () => {
|
||||
const [serialized] = serializeSessionMessages([{
|
||||
id: 'assistant-memory-snapshot',
|
||||
role: 'assistant',
|
||||
text: '申请预览',
|
||||
applicationPreview: {
|
||||
fields: { reason: '客户现场实施' },
|
||||
memoryApplications: [{
|
||||
memoryId: 'memory-local-applied',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'applied'
|
||||
}]
|
||||
},
|
||||
applicationLearningReceipts: [{
|
||||
memoryId: 'memory-local-candidate',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'candidate'
|
||||
}]
|
||||
}])
|
||||
const restored = normalizeSnapshotMessage(serialized)
|
||||
|
||||
assert.equal(restored.applicationPreview.memoryApplications[0].status, 'applied')
|
||||
assert.equal(restored.applicationLearningReceipts[0].status, 'candidate')
|
||||
})
|
||||
|
||||
test('user 消息不能从 payload 恢复 application preview', () => {
|
||||
const [message] = normalizeInitialConversationMessages({
|
||||
messages: [{
|
||||
|
||||
Reference in New Issue
Block a user