feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源 - 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore 及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿 - 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局 - 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
@@ -99,6 +99,10 @@ const FIELD_VALUE_DISPLAY_CONFIG = {
|
||||
}
|
||||
}
|
||||
|
||||
const FLOW_EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费'
|
||||
}
|
||||
|
||||
export function buildStewardPlanRequest({
|
||||
rawText = '',
|
||||
files = [],
|
||||
@@ -216,6 +220,10 @@ export function buildStewardPlanMessageText(plan) {
|
||||
if (isPendingFlowConfirmationPlan(normalized)) {
|
||||
return buildPendingFlowConfirmationMessageText(normalized)
|
||||
}
|
||||
const genericReimbursementTask = normalized.tasks.find((task) => isGenericReimbursementTask(task))
|
||||
if (genericReimbursementTask && normalized.tasks.length === 1) {
|
||||
return buildGenericReimbursementIntentMessageText(genericReimbursementTask)
|
||||
}
|
||||
const nextContext = resolveNextActionContext(normalized)
|
||||
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
|
||||
const taskLines = orderedTasks.map((task, index) =>
|
||||
@@ -289,6 +297,42 @@ export function formatStewardOntologyFields(fields = {}, taskType = '') {
|
||||
.join(';')
|
||||
}
|
||||
|
||||
function buildStewardOntologyFieldRows(fields = {}, taskType = '') {
|
||||
return Object.entries(fields || {})
|
||||
.filter(([, value]) => String(value || '').trim())
|
||||
.map(([key, value]) => {
|
||||
const field = resolveFieldDisplay(key, taskType)
|
||||
return {
|
||||
label: field.label,
|
||||
value: formatStewardFieldDisplayValue(field.key, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function escapeMarkdownTableCell(value) {
|
||||
return String(value || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function formatStewardOntologyFieldsTable(fields = {}, taskType = '') {
|
||||
const rows = buildStewardOntologyFieldRows(fields, taskType)
|
||||
if (!rows.length) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
'| 字段 | 内容 |',
|
||||
'| --- | --- |',
|
||||
...rows.map((row) => `| ${escapeMarkdownTableCell(row.label)} | ${escapeMarkdownTableCell(row.value)} |`)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function resolveCandidateFlowExpenseType(flow = {}) {
|
||||
const rawType = String(flow?.ontologyFields?.expense_type || flow?.ontologyFields?.expenseType || '').trim()
|
||||
if (rawType === '差旅' || rawType === 'travel') {
|
||||
return 'travel'
|
||||
}
|
||||
return rawType
|
||||
}
|
||||
|
||||
export function buildStewardSuggestedActions(plan) {
|
||||
const normalized = normalizeStewardPlan(plan)
|
||||
if (isOffTopicPlan(normalized)) {
|
||||
@@ -304,26 +348,32 @@ export function buildStewardSuggestedActions(plan) {
|
||||
}))
|
||||
}
|
||||
if (isPendingFlowConfirmationPlan(normalized)) {
|
||||
return normalized.candidateFlows.map((flow) => ({
|
||||
label: flow.label,
|
||||
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
|
||||
icon: flow.flowId === 'travel_application'
|
||||
? 'mdi mdi-file-plus-outline'
|
||||
: 'mdi mdi-receipt-text-plus-outline',
|
||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
payload: {
|
||||
steward_confirm_flow: true,
|
||||
steward_plan_id: normalized.planId,
|
||||
flow_id: flow.flowId,
|
||||
session_type: flow.flowId === 'travel_application'
|
||||
? SESSION_TYPE_APPLICATION
|
||||
: SESSION_TYPE_EXPENSE,
|
||||
selected_flow_label: flow.label,
|
||||
carry_text: flow.label,
|
||||
auto_submit: true,
|
||||
steward_state: normalized.stewardState || null
|
||||
return normalized.candidateFlows.map((flow) => {
|
||||
const expenseType = resolveCandidateFlowExpenseType(flow)
|
||||
return {
|
||||
label: flow.label,
|
||||
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
|
||||
icon: flow.flowId === 'travel_application'
|
||||
? 'mdi mdi-file-plus-outline'
|
||||
: 'mdi mdi-receipt-text-plus-outline',
|
||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
payload: {
|
||||
steward_confirm_flow: true,
|
||||
steward_plan_id: normalized.planId,
|
||||
flow_id: flow.flowId,
|
||||
session_type: flow.flowId === 'travel_application'
|
||||
? SESSION_TYPE_APPLICATION
|
||||
: SESSION_TYPE_EXPENSE,
|
||||
selected_flow_label: flow.label,
|
||||
expense_type: expenseType,
|
||||
expense_type_label: FLOW_EXPENSE_TYPE_LABELS[expenseType] || '',
|
||||
requires_application_before_reimbursement: flow.flowId === 'travel_reimbursement' && expenseType === 'travel',
|
||||
carry_text: flow.flowId === 'travel_reimbursement' && expenseType === 'travel' ? '我要报销' : flow.label,
|
||||
auto_submit: true,
|
||||
steward_state: normalized.stewardState || null
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
const nextContext = resolveNextActionContext(normalized)
|
||||
if (!nextContext) {
|
||||
@@ -335,7 +385,7 @@ export function buildStewardSuggestedActions(plan) {
|
||||
: SESSION_TYPE_EXPENSE
|
||||
return [
|
||||
{
|
||||
label: buildNextActionLabel(actionType),
|
||||
label: buildNextActionLabel(actionType, task),
|
||||
description: buildNextActionDescription(actionType, normalized, task, group),
|
||||
icon: actionType === 'confirm_create_application'
|
||||
? 'mdi mdi-file-plus-outline'
|
||||
@@ -411,40 +461,58 @@ export function isOffTopicStewardPlan(rawPlan) {
|
||||
}
|
||||
|
||||
function buildOffTopicMessageText(normalized) {
|
||||
// off_topic 计划的引导文案完全由后端生成(含 ### 标题 + 正文 + 引导句),
|
||||
// 前端透传 summary 即可,避免重复拼接导致与后端表达不一致。
|
||||
const summary = String(normalized?.summary || '').trim()
|
||||
const summaryLine = summary && summary !== '这看起来跟财务任务没什么关系...'
|
||||
? summary
|
||||
: '这看起来跟财务任务没什么关系,我目前只能帮你处理**费用申请**和**费用报销**两类事项。'
|
||||
return [
|
||||
'### 小财管家没看懂这件事',
|
||||
'',
|
||||
summaryLine,
|
||||
'',
|
||||
'你可以试试下面这些方式告诉我:'
|
||||
].join('\n')
|
||||
if (summary) {
|
||||
return summary
|
||||
}
|
||||
return (
|
||||
'### 这句话我暂时没识别到财务事项\n\n' +
|
||||
'很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n' +
|
||||
'要不您换种说法告诉我:'
|
||||
)
|
||||
}
|
||||
|
||||
function buildPendingFlowConfirmationMessageText(normalized) {
|
||||
const fields = normalized.candidateFlows[0]?.ontologyFields || {}
|
||||
const knownParts = formatStewardOntologyFields(fields, 'expense_application')
|
||||
const knownTable = formatStewardOntologyFieldsTable(fields, 'expense_application')
|
||||
const candidateLines = normalized.candidateFlows.map((flow, index) =>
|
||||
`${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}`
|
||||
)
|
||||
const singleCandidate = normalized.candidateFlows.length === 1
|
||||
return [
|
||||
'### 需要先确认流程方向',
|
||||
'',
|
||||
knownParts
|
||||
? `我识别到这是一项财务事项,已提取到:**${knownParts}**。`
|
||||
knownTable
|
||||
? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n')
|
||||
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
|
||||
'',
|
||||
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
|
||||
'',
|
||||
...candidateLines,
|
||||
'',
|
||||
'请先选择一个方向,我会继续整理对应材料。'
|
||||
singleCandidate
|
||||
? `请先点击下方 **${normalized.candidateFlows[0].label}**,我会继续整理对应材料。`
|
||||
: '请先选择一个方向,我会继续整理对应材料。'
|
||||
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
|
||||
}
|
||||
|
||||
function buildGenericReimbursementIntentMessageText() {
|
||||
return [
|
||||
'### 我来带你发起报销',
|
||||
'',
|
||||
'你现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带你填。',
|
||||
'',
|
||||
'1. **先选报销场景**',
|
||||
' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。',
|
||||
'2. **再补关键材料**',
|
||||
' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮你核对是否需要关联事前申请。',
|
||||
'',
|
||||
'点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function resolveNextActionContext(normalized) {
|
||||
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
|
||||
const applicationAction = applicationTask
|
||||
@@ -566,6 +634,9 @@ function buildTaskOrderActionDescription(task) {
|
||||
return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。`
|
||||
}
|
||||
if (task.taskType === 'reimbursement') {
|
||||
if (isGenericReimbursementTask(task)) {
|
||||
return `我会请${agent}先带你选择报销场景,再逐步补齐事由、时间、金额和票据。`
|
||||
}
|
||||
return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。`
|
||||
}
|
||||
return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。`
|
||||
@@ -603,13 +674,16 @@ function buildNextTaskLead(task) {
|
||||
return `处理“${task.title || task.taskTypeLabel}”`
|
||||
}
|
||||
|
||||
function buildNextActionLabel(actionType) {
|
||||
function buildNextActionLabel(actionType, task = null) {
|
||||
if (actionType === 'confirm_create_application') {
|
||||
return '确定,先创建申请单'
|
||||
}
|
||||
if (actionType === 'confirm_attachment_group') {
|
||||
return '确定,确认附件归集'
|
||||
}
|
||||
if (isGenericReimbursementTask(task)) {
|
||||
return '确定,选择报销场景'
|
||||
}
|
||||
return '确定,继续填写报销单'
|
||||
}
|
||||
|
||||
@@ -627,7 +701,29 @@ function buildNextActionDescription(actionType, normalized, task, group) {
|
||||
}
|
||||
return group?.attachmentNames?.length
|
||||
? `报销助手会带入 ${group.attachmentNames.length} 份相关附件生成核对结果。`
|
||||
: '报销助手会根据当前任务生成报销核对结果。'
|
||||
: isGenericReimbursementTask(task)
|
||||
? '先进入报销助手选择具体费用类型,再按场景补齐事由、时间、金额和票据。'
|
||||
: '报销助手会根据当前任务生成报销核对结果。'
|
||||
}
|
||||
|
||||
function isGenericReimbursementTask(task) {
|
||||
if (!task || task.taskType !== 'reimbursement') {
|
||||
return false
|
||||
}
|
||||
const fields = task.ontologyFields || {}
|
||||
const expenseType = String(fields.expense_type || '').trim()
|
||||
const hasSpecificField = ['time_range', 'location', 'amount', 'attachments', 'transport_mode']
|
||||
.some((key) => String(fields[key] || '').trim())
|
||||
|| isSpecificReimbursementReason(fields.reason)
|
||||
return !hasSpecificField && (!expenseType || expenseType === 'other')
|
||||
}
|
||||
|
||||
function isSpecificReimbursementReason(value) {
|
||||
const text = String(value || '').trim().replace(/\s+/g, '')
|
||||
if (!text) {
|
||||
return false
|
||||
}
|
||||
return !/^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销(?:费用|报销单|报销流程)?$/.test(text)
|
||||
}
|
||||
|
||||
function buildStewardCarryText(actionType, task, group, normalized = null) {
|
||||
@@ -644,6 +740,9 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
|
||||
if (!task) {
|
||||
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
|
||||
}
|
||||
if (actionType === 'confirm_create_reimbursement_draft' && isGenericReimbursementTask(task)) {
|
||||
return '我要报销'
|
||||
}
|
||||
|
||||
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
|
||||
const missingFields = formatStewardMissingFieldList(
|
||||
|
||||
Reference in New Issue
Block a user