feat(web): AI 工作台多 task 串行推进与会话适配
- useWorkbenchAiApplicationPreviewFlow/useWorkbenchAiActionRouter/useWorkbenchAiCommandIntents 支持 task1 完成后自动推进 task2,确认按钮直接拉起申请预览,草稿/提交成功后继续推进下一 task - workbenchAiIntentPlannerModel/workbenchAiMessageModel/workbenchAiCommandIntentModel 适配多 task 意图规划与消息结构 - aiApplicationPreviewActions/aiApplicationPrecheckModel/aiExpenseDraftModel/aiWorkbenchConversationStore 草稿与会话存储适配 - PersonalWorkbenchAiMode 与样式适配,更新 preview-actions/expense-draft/conversation-store/fast-preview/action-router/command-intent/intent-planner 测试
This commit is contained in:
@@ -258,7 +258,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
toast
|
||||
toast,
|
||||
onApplicationActionCompleted: startModelPlannedNextTask
|
||||
})
|
||||
|
||||
const expenseFlow = useWorkbenchAiExpenseFlow({
|
||||
@@ -710,6 +711,46 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
return pendingMessage
|
||||
}
|
||||
|
||||
function buildModelPlannedNextTaskAction(remainingTasks = []) {
|
||||
const tasks = Array.isArray(remainingTasks) ? remainingTasks : []
|
||||
const nextTask = tasks[0]
|
||||
if (!nextTask || typeof nextTask !== 'object') {
|
||||
return null
|
||||
}
|
||||
const taskType = String(nextTask.task_type || nextTask.taskType || '').trim()
|
||||
const assignedAgent = String(nextTask.assigned_agent || nextTask.assignedAgent || '').trim()
|
||||
const isApplication = taskType === 'expense_application' || assignedAgent === 'application_assistant'
|
||||
const isReimbursement = taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
|
||||
if (!isApplication && !isReimbursement) {
|
||||
return null
|
||||
}
|
||||
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
|
||||
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
|
||||
const taskLabel = isApplication ? '出差申请' : '费用报销'
|
||||
return {
|
||||
label: `继续处理${taskLabel}`,
|
||||
action_type: 'steward_continue_next_task',
|
||||
payload: {
|
||||
steward_confirm_flow: true,
|
||||
flow_id: flowId,
|
||||
steward_current_task: nextTask,
|
||||
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
|
||||
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
|
||||
ontology_fields: ontologyFields,
|
||||
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
|
||||
steward_remaining_tasks: tasks.slice(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startModelPlannedNextTask(remainingTasks = []) {
|
||||
const nextTaskAction = buildModelPlannedNextTaskAction(remainingTasks)
|
||||
if (!nextTaskAction) {
|
||||
return
|
||||
}
|
||||
actionRouter.handleInlineSuggestedAction(nextTaskAction)
|
||||
}
|
||||
|
||||
function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
|
||||
void applicationFlow.startAiApplicationPreview(
|
||||
travelApplicationRequest.expenseType,
|
||||
@@ -723,7 +764,10 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
autoSubmit: travelApplicationRequest.autoSubmit,
|
||||
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
|
||||
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
|
||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
|
||||
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks,
|
||||
onPreviewReadyForNextTask: startModelPlannedNextTask,
|
||||
onApplicationActionCompleted: startModelPlannedNextTask
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -741,7 +785,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
autoSubmit: travelApplicationRequest.autoSubmit,
|
||||
autoSaveDraft: travelApplicationRequest.autoSaveDraft,
|
||||
requestedSubmit: travelApplicationRequest.requestedSubmit,
|
||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation
|
||||
submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation,
|
||||
stewardRemainingTasks: travelApplicationRequest.stewardRemainingTasks
|
||||
}
|
||||
}
|
||||
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
import { executeStewardAction } from '../../services/steward.js'
|
||||
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
|
||||
import { buildAiExpenseDraftPrefillValues } from '../../utils/aiExpenseDraftModel.js'
|
||||
import { requiresApplicationBeforeReimbursement } from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
@@ -82,6 +84,9 @@ export function useWorkbenchAiActionRouter({
|
||||
}
|
||||
if (actionType === 'ai_application_confirm_intent') {
|
||||
aiExpenseDraft.value = null
|
||||
const stewardRemainingTasks = Array.isArray(actionPayload.stewardRemainingTasks)
|
||||
? actionPayload.stewardRemainingTasks
|
||||
: (Array.isArray(actionPayload.steward_remaining_tasks) ? actionPayload.steward_remaining_tasks : [])
|
||||
void applicationFlow.startAiApplicationPreview('travel', '差旅费', String(actionPayload.sourceText || '').trim(), {
|
||||
userMessage: String(actionPayload.sourceText || '').trim() || '确认发起出差申请',
|
||||
pushUserMessage: true,
|
||||
@@ -89,7 +94,20 @@ export function useWorkbenchAiActionRouter({
|
||||
autoSubmit: Boolean(actionPayload.autoSubmit),
|
||||
autoSaveDraft: Boolean(actionPayload.autoSaveDraft),
|
||||
requestedSubmit: Boolean(actionPayload.requestedSubmit),
|
||||
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation)
|
||||
submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation),
|
||||
stewardRemainingTasks,
|
||||
onPreviewReadyForNextTask: (remainingTasks = []) => {
|
||||
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
|
||||
if (nextTaskAction) {
|
||||
handleInlineSuggestedAction(nextTaskAction)
|
||||
}
|
||||
},
|
||||
onApplicationActionCompleted: (remainingTasks = []) => {
|
||||
const nextTaskAction = buildNextTaskSuggestedAction({ steward_remaining_tasks: remainingTasks })
|
||||
if (nextTaskAction) {
|
||||
handleInlineSuggestedAction(nextTaskAction)
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -104,9 +122,21 @@ export function useWorkbenchAiActionRouter({
|
||||
return
|
||||
}
|
||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
|
||||
const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
|
||||
const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
|
||||
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true)
|
||||
const currentTask = actionPayload.steward_current_task || {}
|
||||
const ontologyFields = currentTask.ontology_fields || currentTask.ontologyFields || actionPayload.ontology_fields || {}
|
||||
const expenseType = String(actionPayload.expense_type || ontologyFields.expense_type || 'travel').trim() || 'travel'
|
||||
const expenseTypeLabel = String(actionPayload.expense_type_label || ontologyFields.expense_type_label || '差旅费').trim() || '差旅费'
|
||||
// 从 task ontology 解析报销语义(金额/时间/事由/地点),预填到报销草稿,
|
||||
// 让 task2(如业务招待费 2000 元)的信息直接落到草稿,而不是丢失。
|
||||
const prefillValues = buildAiExpenseDraftPrefillValues(ontologyFields)
|
||||
const needsApplicationLink = requiresApplicationBeforeReimbursement(expenseType)
|
||||
const stewardRemainingTasks = Array.isArray(actionPayload.steward_remaining_tasks)
|
||||
? actionPayload.steward_remaining_tasks
|
||||
: []
|
||||
expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, needsApplicationLink, {
|
||||
prefillValues,
|
||||
stewardRemainingTasks
|
||||
})
|
||||
return
|
||||
}
|
||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
|
||||
@@ -176,7 +206,18 @@ export function useWorkbenchAiActionRouter({
|
||||
|
||||
if (actionType === 'ai_application_start_inline') {
|
||||
aiExpenseDraft.value = null
|
||||
void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
|
||||
// 多 task 推进:从 resolveAiExpenseApplicationLink "查不到申请单"分支过来的按钮,
|
||||
// payload 里带 prefill_values 和 steward_remaining_tasks,这里透传给申请预览,
|
||||
// 保证发起的申请单带着报销语义,且申请单做完后能继续 task2 报销流程。
|
||||
void expenseFlow.startAiApplicationPreviewFromAction({
|
||||
...(action?.payload || {}),
|
||||
expense_type: actionPayload.expense_type,
|
||||
expense_type_label: actionPayload.expense_type_label,
|
||||
carry_text: actionPayload.carry_text || actionPayload.original_message || action?.label,
|
||||
steward_remaining_tasks: Array.isArray(actionPayload.steward_remaining_tasks)
|
||||
? actionPayload.steward_remaining_tasks
|
||||
: []
|
||||
}, action?.label)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -398,6 +439,9 @@ export function useWorkbenchAiActionRouter({
|
||||
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
|
||||
const taskLabel = isApplication ? '出差申请' : '费用报销'
|
||||
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
|
||||
// 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进,
|
||||
// 避免 3+ task 场景在 task2 处断链。
|
||||
const furtherRemainingTasks = remainingTasks.slice(1)
|
||||
return {
|
||||
label: `继续处理${taskLabel}`,
|
||||
description: `接下来处理${taskLabel}: ${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`,
|
||||
@@ -410,7 +454,8 @@ export function useWorkbenchAiActionRouter({
|
||||
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
|
||||
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
|
||||
ontology_fields: ontologyFields,
|
||||
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim()
|
||||
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
|
||||
steward_remaining_tasks: furtherRemainingTasks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
resolveLatestInlineUserPrompt,
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
toast
|
||||
toast,
|
||||
onApplicationActionCompleted = null
|
||||
}) {
|
||||
function isApplicationPreviewEstimatePending(message = {}) {
|
||||
return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
|
||||
@@ -345,6 +346,9 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
|
||||
const taskLabel = isApplication ? '出差申请' : '费用报销'
|
||||
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
|
||||
// 透传去掉当前 nextTask 之后的剩余 task 列表,保证 task2 完成后 task3 也能继续推进,
|
||||
// 避免 3+ task 场景在 task2 处断链。
|
||||
const furtherRemainingTasks = remainingTasks.slice(1)
|
||||
return {
|
||||
label: `继续处理${taskLabel}`,
|
||||
description: `接下来处理${taskLabel}:${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`,
|
||||
@@ -357,7 +361,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
|
||||
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
|
||||
ontology_fields: ontologyFields,
|
||||
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim()
|
||||
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
|
||||
steward_remaining_tasks: furtherRemainingTasks
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,6 +463,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
targetMessage.suggestedActions = []
|
||||
const detailActions = buildInlineApplicationDetailAction(draftPayload)
|
||||
const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage)
|
||||
const shouldAutoContinueNextTask = Boolean(
|
||||
nextTaskAction &&
|
||||
typeof onApplicationActionCompleted === 'function' &&
|
||||
Array.isArray(targetMessage.stewardRemainingTasks) &&
|
||||
targetMessage.stewardRemainingTasks.length
|
||||
)
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
|
||||
@@ -466,11 +477,16 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
streamStatus: 'completed',
|
||||
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
|
||||
},
|
||||
suggestedActions: nextTaskAction ? [...detailActions, nextTaskAction] : detailActions
|
||||
suggestedActions: shouldAutoContinueNextTask
|
||||
? detailActions
|
||||
: (nextTaskAction ? [...detailActions, nextTaskAction] : detailActions)
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
if (shouldAutoContinueNextTask) {
|
||||
onApplicationActionCompleted(targetMessage.stewardRemainingTasks, targetMessage)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
replaceInlineMessage(
|
||||
@@ -599,6 +615,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
skipUserMessage: true,
|
||||
userText: options.userMessage || '保存草稿'
|
||||
})
|
||||
} else if (
|
||||
typeof options.onPreviewReadyForNextTask === 'function' &&
|
||||
Array.isArray(previewMessage.stewardRemainingTasks) &&
|
||||
previewMessage.stewardRemainingTasks.length
|
||||
) {
|
||||
options.onPreviewReadyForNextTask(previewMessage.stewardRemainingTasks, previewMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
buildWorkbenchDocumentCommandFollowupGuidance,
|
||||
buildWorkbenchDraftDeletionGuidance,
|
||||
isWorkbenchDraftDeletionIntent,
|
||||
resolveLatestWorkbenchDocumentCommandContext,
|
||||
resolveLatestWorkbenchDraftPayload
|
||||
} from './workbenchAiCommandIntentModel.js'
|
||||
import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js'
|
||||
@@ -58,6 +60,9 @@ export function useWorkbenchAiCommandIntents({
|
||||
if (!handlesWorkbenchCommand) {
|
||||
return false
|
||||
}
|
||||
const documentCommandContext = route.nextStep === 'query_candidates'
|
||||
? resolveLatestWorkbenchDocumentCommandContext(conversationMessages.value, frame)
|
||||
: null
|
||||
prepareInlineCommandConversation(cleanPrompt, entry)
|
||||
const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete
|
||||
? resolveLatestWorkbenchDraftPayload(conversationMessages.value)
|
||||
@@ -72,6 +77,16 @@ export function useWorkbenchAiCommandIntents({
|
||||
return true
|
||||
}
|
||||
|
||||
if (route.nextStep === 'query_candidates' && documentCommandContext) {
|
||||
const guidance = buildWorkbenchDocumentCommandFollowupGuidance(documentCommandContext, frame)
|
||||
conversationMessages.value.push(createInlineMessage('assistant', guidance.content, {
|
||||
suggestedActions: guidance.suggestedActions
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
return true
|
||||
}
|
||||
|
||||
const queryPrompt = route.queryPrompt || frame?.normalizedQuery || '我的草稿单据'
|
||||
const pendingText = frame?.safetyLevel === 'confirm_required'
|
||||
? '正在先筛选候选单据,不会直接执行删除或审核动作...'
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../services/linkedReimbursementDraftJobs.js'
|
||||
import {
|
||||
applyAiExpenseAnswer,
|
||||
buildAiExpenseDraftPrefillValues,
|
||||
buildAiExpenseStepPrompt,
|
||||
buildAiExpenseSummary,
|
||||
createAiExpenseDraft,
|
||||
@@ -113,6 +114,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
|
||||
draftPayload: options.draftPayload || null,
|
||||
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
|
||||
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
|
||||
text: options.text || content
|
||||
})
|
||||
replaceInlineMessage(messageId, nextMessage)
|
||||
@@ -323,7 +325,40 @@ export function useWorkbenchAiExpenseFlow({
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
|
||||
// 多 task 推进时,把当前报销流程后续要处理的剩余 task 挂在 draft 上,
|
||||
// 这样关联申请单、发起申请单、生成报销草稿等子流程都能把 remaining tasks 透传下去,
|
||||
// 保证 task2 完成后能继续 task3。draft 被清空时上下文也随之消失。
|
||||
function attachStewardRemainingTasks(draft, stewardRemainingTasks) {
|
||||
if (!draft) {
|
||||
return draft
|
||||
}
|
||||
const tasks = Array.isArray(stewardRemainingTasks) ? stewardRemainingTasks : []
|
||||
draft.stewardRemainingTasks = tasks
|
||||
return draft
|
||||
}
|
||||
|
||||
function resolveStewardRemainingTasks(draft) {
|
||||
const draftTasks = Array.isArray(draft?.stewardRemainingTasks) ? draft.stewardRemainingTasks : []
|
||||
return draftTasks.length ? draftTasks : null
|
||||
}
|
||||
|
||||
// 把 expenseType 解析成"发起 XX 申请"按钮里的 XX,避免对招待费也显示"出差申请"。
|
||||
function resolveRequiredApplicationLabel(expenseType, fallbackLabel = '') {
|
||||
const normalized = String(expenseType || '').trim().toLowerCase()
|
||||
if (normalized === 'meal' || normalized === 'entertainment') {
|
||||
return '业务招待'
|
||||
}
|
||||
if (normalized === 'travel') {
|
||||
return '出差'
|
||||
}
|
||||
const label = String(fallbackLabel || '').trim()
|
||||
if (label) {
|
||||
return label.replace(/费$/, '')
|
||||
}
|
||||
return '费用'
|
||||
}
|
||||
|
||||
function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement, options = {}) {
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
|
||||
}
|
||||
@@ -333,12 +368,25 @@ export function useWorkbenchAiExpenseFlow({
|
||||
clearAiModeFiles()
|
||||
pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
|
||||
|
||||
const prefillValues = options.prefillValues && typeof options.prefillValues === 'object'
|
||||
? options.prefillValues
|
||||
: null
|
||||
const stewardRemainingTasks = Array.isArray(options.stewardRemainingTasks)
|
||||
? options.stewardRemainingTasks
|
||||
: []
|
||||
|
||||
if (requiresApplicationBeforeReimbursement) {
|
||||
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel)
|
||||
void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel, {
|
||||
prefillValues,
|
||||
stewardRemainingTasks
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const draft = createAiExpenseDraft(expenseType, expenseTypeLabel)
|
||||
const draft = attachStewardRemainingTasks(
|
||||
createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues),
|
||||
stewardRemainingTasks
|
||||
)
|
||||
aiExpenseDraft.value = draft
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
|
||||
persistCurrentConversation()
|
||||
@@ -351,7 +399,11 @@ export function useWorkbenchAiExpenseFlow({
|
||||
assistantDraft.value = ''
|
||||
clearAiModeFiles()
|
||||
|
||||
const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames)
|
||||
const currentDraft = aiExpenseDraft.value
|
||||
const next = applyAiExpenseAnswer(currentDraft, answer, fileNames)
|
||||
// applyAiExpenseAnswer 不会保留 draft 上的运行时上下文,这里手动透传 remaining tasks,
|
||||
// 保证报销草稿收集完所有字段后,仍能拿到后续 task 列表用于推进 task3。
|
||||
attachStewardRemainingTasks(next, resolveStewardRemainingTasks(currentDraft))
|
||||
aiExpenseDraft.value = next
|
||||
|
||||
if (isAiExpenseDraftComplete(next)) {
|
||||
@@ -364,7 +416,14 @@ export function useWorkbenchAiExpenseFlow({
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
|
||||
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel, options = {}) {
|
||||
const prefillValues = options.prefillValues && typeof options.prefillValues === 'object'
|
||||
? options.prefillValues
|
||||
: null
|
||||
const stewardRemainingTasks = Array.isArray(options.stewardRemainingTasks)
|
||||
? options.stewardRemainingTasks
|
||||
: []
|
||||
|
||||
let claims = null
|
||||
try {
|
||||
claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
@@ -377,18 +436,30 @@ export function useWorkbenchAiExpenseFlow({
|
||||
}
|
||||
|
||||
const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {})
|
||||
aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel)
|
||||
// 即使后续可能被清空,也先把报销语义 + remaining tasks 上下文挂到 draft 上,
|
||||
// 这样查不到申请单时仍能透传给"发起申请单"按钮,保证 task2 不丢失语义。
|
||||
const draft = attachStewardRemainingTasks(
|
||||
createAiExpenseDraft(expenseType, expenseTypeLabel, prefillValues),
|
||||
stewardRemainingTasks
|
||||
)
|
||||
aiExpenseDraft.value = draft
|
||||
|
||||
if (!candidates.length) {
|
||||
// 查不到可关联申请单:不要让 task2 语义丢失。生成"发起申请单"按钮时,
|
||||
// 按费用类型动态生成 label,带上 ontology 上下文 + remaining tasks,
|
||||
// 让用户发起申请单后能回到 task2 报销流程(见 ai_application_start_inline 分支)。
|
||||
const applicationLabel = resolveRequiredApplicationLabel(expenseType, expenseTypeLabel)
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
|
||||
suggestedActions: [{
|
||||
label: '确认发起出差申请',
|
||||
label: `确认发起${applicationLabel}申请`,
|
||||
description: '生成完整申请表,并预填已识别的时间、地点和事由',
|
||||
icon: 'mdi mdi-file-plus-outline',
|
||||
action_type: 'ai_application_start_inline',
|
||||
payload: {
|
||||
expense_type: expenseType,
|
||||
expense_type_label: expenseTypeLabel
|
||||
expense_type_label: expenseTypeLabel,
|
||||
prefill_values: prefillValues || {},
|
||||
steward_remaining_tasks: stewardRemainingTasks
|
||||
}
|
||||
}]
|
||||
}))
|
||||
@@ -459,7 +530,8 @@ export function useWorkbenchAiExpenseFlow({
|
||||
jobId,
|
||||
pendingMessageId,
|
||||
claimNo = '',
|
||||
initialJob = null
|
||||
initialJob = null,
|
||||
stewardRemainingTasks = []
|
||||
}) {
|
||||
const normalizedJobId = String(jobId || '').trim()
|
||||
if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) {
|
||||
@@ -479,13 +551,17 @@ export function useWorkbenchAiExpenseFlow({
|
||||
const content = draftClaimNo
|
||||
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||
// 多 task 推进:报销草稿生成成功后,若还有剩余 task,补一个"继续处理"按钮。
|
||||
const nextTaskAction = buildExpenseDraftNextTaskAction(stewardRemainingTasks)
|
||||
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||
draftPayload,
|
||||
linkedReimbursementDraftJob: {
|
||||
...currentJob,
|
||||
applicationClaimNo: claimNo
|
||||
},
|
||||
suggestedActions: buildLinkedDraftAction(draftPayload)
|
||||
suggestedActions: nextTaskAction
|
||||
? [...buildLinkedDraftAction(draftPayload), nextTaskAction]
|
||||
: buildLinkedDraftAction(draftPayload)
|
||||
})
|
||||
aiExpenseDraft.value = null
|
||||
persistCurrentConversation()
|
||||
@@ -524,7 +600,9 @@ export function useWorkbenchAiExpenseFlow({
|
||||
jobId: job.jobId,
|
||||
pendingMessageId: message.id,
|
||||
claimNo: job.applicationClaimNo,
|
||||
initialJob: job
|
||||
initialJob: job,
|
||||
// 刷新恢复时从消息上读回 remaining tasks,保证报销完成后仍能补出"继续处理"按钮。
|
||||
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
|
||||
}).catch((error) => {
|
||||
replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), {
|
||||
linkedReimbursementDraftJob: {
|
||||
@@ -556,6 +634,38 @@ export function useWorkbenchAiExpenseFlow({
|
||||
}]
|
||||
}
|
||||
|
||||
// 报销草稿生成成功后,若有剩余 task,生成"继续处理下一个任务"按钮。
|
||||
// 与 useWorkbenchAiApplicationPreviewFlow.buildApplicationPreviewNextTaskAction 同构,
|
||||
// 但数据源是 draft 上透传过来的 stewardRemainingTasks,保证报销完成后 task3 也能推进。
|
||||
function buildExpenseDraftNextTaskAction(remainingTasks = []) {
|
||||
const tasks = Array.isArray(remainingTasks) ? remainingTasks : []
|
||||
const nextTask = tasks[0]
|
||||
if (!nextTask || !nextTask.task_type) {
|
||||
return null
|
||||
}
|
||||
const taskType = String(nextTask.task_type || '').trim()
|
||||
const isApplication = taskType === 'expense_application'
|
||||
const flowId = isApplication ? 'travel_application' : 'travel_reimbursement'
|
||||
const taskLabel = isApplication ? '出差申请' : '费用报销'
|
||||
const ontologyFields = nextTask.ontology_fields || nextTask.ontologyFields || {}
|
||||
return {
|
||||
label: `继续处理${taskLabel}`,
|
||||
description: `接下来处理${taskLabel}:${String(nextTask.summary || nextTask.title || '').slice(0, 40)}`,
|
||||
icon: isApplication ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline',
|
||||
action_type: 'steward_continue_next_task',
|
||||
payload: {
|
||||
steward_confirm_flow: true,
|
||||
flow_id: flowId,
|
||||
steward_current_task: nextTask,
|
||||
expense_type: String(ontologyFields.expense_type || 'travel').trim() || 'travel',
|
||||
expense_type_label: String(ontologyFields.expense_type_label || '差旅费').trim() || '差旅费',
|
||||
ontology_fields: ontologyFields,
|
||||
original_message: String(nextTask.summary || nextTask.title || `继续处理${taskLabel}`).trim(),
|
||||
steward_remaining_tasks: tasks.slice(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function linkAiExpenseApplication(application = {}) {
|
||||
const draft = aiExpenseDraft.value || (() => {
|
||||
const resolved = resolveRequiredApplicationReimbursementType(application)
|
||||
@@ -577,9 +687,14 @@ export function useWorkbenchAiExpenseFlow({
|
||||
stepKey: 'attachments'
|
||||
}
|
||||
aiExpenseDraft.value = linked
|
||||
// 关联申请单时,保留 draft 上的 remaining tasks 上下文,透传给后续轮询,
|
||||
// 这样报销草稿生成成功后能补出"继续处理 task3"按钮。
|
||||
const stewardRemainingTasks = resolveStewardRemainingTasks(linked) || []
|
||||
const pendingMessage = createInlineMessage('assistant', `已关联申请单${claimNo ? ` ${claimNo}` : ''},正在生成报销草稿...`, {
|
||||
pending: true,
|
||||
suggestedActions: []
|
||||
suggestedActions: [],
|
||||
// 把 remaining tasks 挂到 pending 消息上,刷新后 resume 轮询能读回并透传给 poll 成功分支。
|
||||
stewardRemainingTasks
|
||||
})
|
||||
conversationMessages.value.push(pendingMessage)
|
||||
const pendingMessageId = pendingMessage.id
|
||||
@@ -607,7 +722,8 @@ export function useWorkbenchAiExpenseFlow({
|
||||
jobId: normalizedJob.jobId,
|
||||
pendingMessageId,
|
||||
claimNo,
|
||||
initialJob: normalizedJob
|
||||
initialJob: normalizedJob,
|
||||
stewardRemainingTasks
|
||||
})
|
||||
} catch (error) {
|
||||
replaceInlineAssistantMessage(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
parseAiDocumentDetailHref
|
||||
} from '../../utils/aiDocumentDetailReference.js'
|
||||
|
||||
const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/
|
||||
const DRAFT_DELETION_TARGET_PATTERN = (
|
||||
/草稿|这个单据|这张单据|当前单据|当前申请|当前报销|刚才保存的草稿|刚才的草稿|上面的单据|最近的单据|申请单|报销单/
|
||||
@@ -22,11 +26,26 @@ const SUBMITTED_OR_FINAL_STATUS = new Set([
|
||||
'已驳回',
|
||||
'已退回'
|
||||
])
|
||||
const DOCUMENT_DETAIL_LINK_RE = /<a\b[^>]*href="([^"]*#ai-open-document-detail:[^"]+)"[^>]*>(.*?)<\/a>/g
|
||||
const DOCUMENT_COMMAND_ACTION_LABELS = {
|
||||
delete: '删除',
|
||||
approve: '审核通过',
|
||||
reject: '驳回/退回'
|
||||
}
|
||||
const DOCUMENT_COMMAND_DETAIL_LABELS = {
|
||||
delete: '进入详情确认删除',
|
||||
approve: '进入详情确认审核',
|
||||
reject: '进入详情确认驳回'
|
||||
}
|
||||
|
||||
function normalizeCompactText(value = '') {
|
||||
return String(value || '').replace(/\s+/g, '').trim()
|
||||
}
|
||||
|
||||
function normalizeText(value = '') {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function normalizeDraftDocumentType(payload = {}, claimNo = '') {
|
||||
const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim()
|
||||
if (/application|expense_application|申请/.test(rawType)) {
|
||||
@@ -77,6 +96,60 @@ function extractDraftPayloadFromSuggestedActions(message = {}) {
|
||||
return null
|
||||
}
|
||||
|
||||
function stripHtml(value = '') {
|
||||
return normalizeText(String(value || '').replace(/<[^>]*>/g, ''))
|
||||
}
|
||||
|
||||
function normalizeDocumentCommandCandidate(detailReference = null, rawLabel = '') {
|
||||
if (!detailReference || typeof detailReference !== 'object') {
|
||||
return null
|
||||
}
|
||||
const claimId = String(detailReference.claimId || detailReference.claim_id || '').trim()
|
||||
const claimNo = String(detailReference.claimNo || detailReference.claim_no || detailReference.reference || '').trim()
|
||||
if (!claimId && !claimNo) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
claimId,
|
||||
claimNo,
|
||||
documentType: normalizeDraftDocumentType(detailReference, claimNo),
|
||||
actionLabel: stripHtml(rawLabel) || '查看详情'
|
||||
}
|
||||
}
|
||||
|
||||
function extractDocumentCommandCandidatesFromContent(content = '') {
|
||||
const text = String(content || '')
|
||||
const candidates = []
|
||||
const seen = new Set()
|
||||
for (const match of text.matchAll(DOCUMENT_DETAIL_LINK_RE)) {
|
||||
const candidate = normalizeDocumentCommandCandidate(
|
||||
parseAiDocumentDetailHref(match[1]),
|
||||
match[2]
|
||||
)
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const key = `${candidate.claimId || ''}:${candidate.claimNo || ''}`
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function canReuseDocumentCommandContext(content = '', commandFrame = {}) {
|
||||
const action = String(commandFrame?.action || '').trim()
|
||||
if (!['approve', 'reject'].includes(action)) {
|
||||
return false
|
||||
}
|
||||
if (String(commandFrame?.safetyLevel || '').trim() !== 'confirm_required') {
|
||||
return false
|
||||
}
|
||||
return /ai-document-card--approval-task|待我审核|待审|待审批|待审核|确认审核|进入详情确认审核/.test(String(content || ''))
|
||||
}
|
||||
|
||||
export function isWorkbenchDraftDeletionIntent(prompt = '') {
|
||||
const compact = normalizeCompactText(prompt)
|
||||
if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) {
|
||||
@@ -104,6 +177,29 @@ export function resolveLatestWorkbenchDraftPayload(messages = []) {
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveLatestWorkbenchDocumentCommandContext(messages = [], commandFrame = {}) {
|
||||
const safeMessages = Array.isArray(messages) ? messages : []
|
||||
for (const message of [...safeMessages].reverse()) {
|
||||
if (String(message?.role || '').trim() !== 'assistant') {
|
||||
continue
|
||||
}
|
||||
const content = String(message?.content || message?.text || '')
|
||||
if (!canReuseDocumentCommandContext(content, commandFrame)) {
|
||||
continue
|
||||
}
|
||||
const candidates = extractDocumentCommandCandidatesFromContent(content)
|
||||
if (!candidates.length) {
|
||||
continue
|
||||
}
|
||||
return {
|
||||
sourceMessageId: String(message?.id || '').trim(),
|
||||
action: String(commandFrame?.action || '').trim(),
|
||||
candidates
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) {
|
||||
const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim()
|
||||
const claimId = String(draftPayload.claimId || draftPayload.claim_id || '').trim()
|
||||
@@ -128,3 +224,42 @@ export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) {
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorkbenchDocumentCommandFollowupGuidance(context = {}, commandFrame = {}) {
|
||||
const action = String(commandFrame?.action || context?.action || '').trim()
|
||||
const actionLabel = DOCUMENT_COMMAND_ACTION_LABELS[action] || '处理'
|
||||
const detailLabel = DOCUMENT_COMMAND_DETAIL_LABELS[action] || '进入详情确认'
|
||||
const candidates = Array.isArray(context?.candidates) ? context.candidates : []
|
||||
const visibleCandidates = candidates.slice(0, 8)
|
||||
const candidateLines = visibleCandidates.map((candidate, index) => {
|
||||
const reference = candidate.claimNo || candidate.claimId || `候选 ${index + 1}`
|
||||
return `${index + 1}. ${reference}`
|
||||
})
|
||||
const overflowText = candidates.length > visibleCandidates.length
|
||||
? `\n\n还有 ${candidates.length - visibleCandidates.length} 张候选未展示,请先补充更具体条件。`
|
||||
: ''
|
||||
return {
|
||||
content: [
|
||||
'### 已接上刚才查询到的待审单据',
|
||||
`您想继续执行 **${actionLabel}**。这属于高风险审批动作,我不会直接替您通过或驳回。`,
|
||||
'请先从刚才的候选单据中选择一张,进入详情页核对风险、金额和审批节点后再确认。',
|
||||
candidateLines.length ? candidateLines.join('\n') : '',
|
||||
overflowText
|
||||
].filter(Boolean).join('\n\n'),
|
||||
suggestedActions: visibleCandidates.map((candidate) => {
|
||||
const reference = candidate.claimNo || candidate.claimId || '单据'
|
||||
return {
|
||||
label: `${detailLabel} ${reference}`,
|
||||
description: '打开详情页核对后,再完成审批确认。',
|
||||
icon: 'mdi mdi-open-in-new',
|
||||
action_type: 'open_application_detail',
|
||||
payload: {
|
||||
claim_id: candidate.claimId,
|
||||
claim_no: candidate.claimNo,
|
||||
document_type: candidate.documentType,
|
||||
command_action: action
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,13 +143,37 @@ function normalizeServerApplicationSteps(rawSteps = []) {
|
||||
return [...new Set(mappedSteps)]
|
||||
}
|
||||
|
||||
function resolveModelTasks(rawPlan = {}) {
|
||||
return Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
|
||||
}
|
||||
|
||||
function isModelTravelApplicationTask(task = {}) {
|
||||
if (!task || typeof task !== 'object') {
|
||||
return false
|
||||
}
|
||||
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'
|
||||
}
|
||||
|
||||
function findModelTravelApplicationTask(rawPlan = {}) {
|
||||
const tasks = Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
|
||||
return tasks.find((task) => {
|
||||
return resolveModelTasks(rawPlan).find(isModelTravelApplicationTask) || null
|
||||
}
|
||||
|
||||
function resolveModelRemainingTasks(rawPlan = {}, selectedTask = null) {
|
||||
const tasks = resolveModelTasks(rawPlan)
|
||||
const selectedIndex = tasks.findIndex((task) => task === selectedTask)
|
||||
if (selectedIndex < 0) {
|
||||
return []
|
||||
}
|
||||
return tasks.slice(selectedIndex + 1).filter((task) => {
|
||||
if (!task || typeof task !== 'object') {
|
||||
return false
|
||||
}
|
||||
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
|
||||
return Boolean(taskType || assignedAgent)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveCandidateFlows(rawPlan = {}) {
|
||||
@@ -211,7 +235,7 @@ export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) {
|
||||
task.requested_action ||
|
||||
task.requestedAction ||
|
||||
rawPlan.requested_action ||
|
||||
rawPlan.requestedAction ||
|
||||
rawPlan.requestedAction ||
|
||||
''
|
||||
).trim() || normalizePromptAction(prompt)
|
||||
const serverSteps = normalizeServerApplicationSteps(task.action_steps || task.actionSteps)
|
||||
@@ -226,7 +250,8 @@ export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) {
|
||||
missingFields: Array.isArray(task.missing_fields || task.missingFields)
|
||||
? task.missing_fields || task.missingFields
|
||||
: [],
|
||||
steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction)
|
||||
steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction),
|
||||
stewardRemainingTasks: resolveModelRemainingTasks(rawPlan, task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +300,7 @@ export function resolveExecutableTravelApplicationPlan(plan = null) {
|
||||
return null
|
||||
}
|
||||
const requestedSubmit = plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION)
|
||||
return {
|
||||
const request = {
|
||||
expenseType: 'travel',
|
||||
expenseTypeLabel: '差旅费',
|
||||
sourceText: String(plan.sourceText || '').trim(),
|
||||
@@ -285,6 +310,11 @@ export function resolveExecutableTravelApplicationPlan(plan = null) {
|
||||
requestedSubmit,
|
||||
submitRequiresConfirmation: requestedSubmit
|
||||
}
|
||||
const stewardRemainingTasks = Array.isArray(plan.stewardRemainingTasks) ? plan.stewardRemainingTasks : []
|
||||
if (stewardRemainingTasks.length) {
|
||||
request.stewardRemainingTasks = stewardRemainingTasks
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
export function isLowConfidenceTravelApplicationPlan(plan = null) {
|
||||
|
||||
@@ -156,6 +156,9 @@ export function createWorkbenchAiMessageRuntime() {
|
||||
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null),
|
||||
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null),
|
||||
attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
|
||||
// 多 task 推进上下文:申请预览/报销草稿消息上挂载剩余 task 列表,
|
||||
// 刷新或消息重建后仍能继续推进,避免 task 链断裂。
|
||||
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],
|
||||
text: options.text || normalizedContent,
|
||||
createdAt: options.createdAt || Date.now()
|
||||
}
|
||||
@@ -175,6 +178,7 @@ export function createWorkbenchAiMessageRuntime() {
|
||||
attachmentAssociationJob: message.attachmentAssociationJob || null,
|
||||
linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null,
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null,
|
||||
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : [],
|
||||
text: message.text || message.content || ''
|
||||
})
|
||||
}
|
||||
@@ -194,7 +198,8 @@ export function createWorkbenchAiMessageRuntime() {
|
||||
draftPayload: message.draftPayload || null,
|
||||
attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null),
|
||||
linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null),
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null
|
||||
attachmentOcrDetails: message.attachmentOcrDetails || null,
|
||||
stewardRemainingTasks: Array.isArray(message.stewardRemainingTasks) ? message.stewardRemainingTasks : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user