import { IMPORT_STEP_KEYS, type ImportStageRequest, type ImportStepKey, type PreflightReport, } from '../imports/imports.types'; import { permittedStepKeys } from '../imports/imports.access'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; import { AiMessage } from './entities'; import { finishToolRun, startToolRun } from './ai-chat.tools'; import { isExcelAttachment, parseConfirmedMapping, parseConfirmedSettings, } from './ai-chat.import-confirm'; function parseAttachmentArgs( parsedArgs: unknown, ): { parsedRecord: Record; attachmentId: number } { const parsedRecord = parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) ? (parsedArgs as Record) : {}; if ( typeof parsedRecord.attachmentId !== 'number' || !Number.isInteger(parsedRecord.attachmentId) || parsedRecord.attachmentId <= 0 ) { throw new Error('缺少附件 attachmentId'); } return { parsedRecord, attachmentId: parsedRecord.attachmentId }; } async function beginImportToolRun( context: AiChatServiceContext, messageId: number, call: ModelToolCall, emit: AiSseEmitter, toolName: string, ) { return startToolRun(context, messageId, call, emit, { toolName, skillKey: null, argumentsData: null, }); } interface ImportToolContext { run: Awaited>['run']; parsedArgs: Awaited>['parsedArgs']; startedAt: Awaited>['startedAt']; assistant: AiMessage; agentContext: AgentToolContext; context: AiChatServiceContext; call: ModelToolCall; emit: AiSseEmitter; messageId: number; } async function runImportTool( context: AiChatServiceContext, messageId: number, call: ModelToolCall, agentContext: AgentToolContext, emit: AiSseEmitter, toolName: string, handler: (tool: ImportToolContext) => Promise, ): Promise { const { run, parsedArgs, startedAt } = await beginImportToolRun( context, messageId, call, emit, toolName, ); try { const assistant = await context.messages.findOne({ where: { id: messageId } }); if (!assistant) throw new Error('assistant message missing'); return await handler({ run, parsedArgs, startedAt, assistant, agentContext, context, call, emit, messageId }); } catch (error) { const summary = error instanceof Error ? error.message.slice(0, 100) : `${toolName} 失败`; await finishToolRun(context, run, call, startedAt, { status: 'failed', summary, error: summary, }, emit); return JSON.stringify({ status: 'failed', error: run.resultSummary }); } } type ImportToolExecutor = ( context: AiChatServiceContext, messageId: number, call: ModelToolCall, agentContext: AgentToolContext, emit: AiSseEmitter, ) => Promise; function makeImportToolExecutor( toolName: 'preflight_import' | 'start_import_wizard', handler: (tool: ImportToolContext) => Promise, ): ImportToolExecutor { return (context, messageId, call, agentContext, emit) => runImportTool(context, messageId, call, agentContext, emit, toolName, handler); } export const executePreflightImport = makeImportToolExecutor( 'preflight_import', async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); const headerRow = parsedRecord.headerRow === undefined ? 1 : Number(parsedRecord.headerRow); if (!Number.isInteger(headerRow) || headerRow < 1 || headerRow > 1000) { throw new Error('headerRow 必须是 1-1000 之间的整数'); } const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [ attachmentId as number, ]); if (!isExcelAttachment(attachment)) { throw new Error('附件不是 Excel 文件,无法预检导入'); } if (!context.importsService) throw new Error('导入预检服务未配置'); const buffer = await context.attachmentService.readStoredBuffer(attachment); const preflight: PreflightReport = await context.importsService.preflightFile({ originalName: attachment.originalName, mimeType: attachment.mimeType, size: attachment.size, buffer, }, headerRow); const permittedSteps = permittedStepKeys({ id: ac.userId, permissions: [...ac.permissions], isSuperAdmin: ac.isSuperAdmin, }); const preflightCard: PreflightReport = { ...preflight, attachmentId: attachment.id, headerRow, permittedSteps, resolved: false, runId: null, }; assistant.metadata = { ...assistant.metadata, a2uiImportPreflight: preflightCard, }; await context.messages.save(assistant); await finishToolRun(context, run, call, startedAt, { status: 'success', summary: `已完成导入预检:${preflight.stages .map((stage) => `${stage.label} ${stage.total} 行`) .join('、') || '未识别到可导入阶段'}`, }, emit); emit('ui.import_preflight', { messageId, preflight: preflightCard }); return preflightModelPayload(preflight, permittedSteps); }); export const executeStartImportWizard = makeImportToolExecutor( 'start_import_wizard', async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [ attachmentId as number, ]); if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导'); const stages = Array.isArray(parsedRecord.stages) ? (parsedRecord.stages as ImportStageRequest[]) : []; if (stages.length === 0) throw new Error('缺少 stages 参数'); for (const stage of stages) { if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) { throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`); } if (!stage.sheet || !String(stage.sheet).trim()) { throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`); } if ( stage.headerRow !== undefined && (!Number.isInteger(stage.headerRow) || stage.headerRow < 1 || stage.headerRow > 1000) ) { throw new Error(`stages 中「${stage.stepKey}」的 headerRow 必须是 1-1000 之间的整数`); } } const mapping = parseConfirmedMapping(parsedRecord.mapping); const settings = parseConfirmedSettings(parsedRecord); if (!context.importsService) throw new Error('导入向导服务未配置'); const buffer = await context.attachmentService.readStoredBuffer(attachment); const detail = await context.importsService.createRun( { id: ac.userId, permissions: [...ac.permissions], isSuperAdmin: ac.isSuperAdmin, }, 'ai', { originalName: attachment.originalName, mimeType: attachment.mimeType, size: attachment.size, buffer, }, assistant.conversationId, stages, mapping, settings, ); const wizard = compactImportWizard(detail); const preflightMeta = assistant.metadata?.a2uiImportPreflight; assistant.metadata = { ...assistant.metadata, ...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta) ? { a2uiImportPreflight: { ...(preflightMeta as Record), resolved: true, runId: detail.id, }, } : {}), a2uiImportWizard: wizard, }; await context.messages.save(assistant); await finishToolRun(context, run, call, startedAt, { status: 'success', summary: `已生成导入向导:${detail.steps .filter((step) => step.status !== 'skipped') .map((step) => step.label) .join('、')}`, }, emit); if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) { emit('ui.import_preflight', { messageId, preflight: { ...(preflightMeta as Record), resolved: true, runId: detail.id, }, }); } emit('ui.import_wizard', { messageId, wizard }); return JSON.stringify({ status: 'success', runId: detail.id, steps: detail.steps .filter((step) => step.status !== 'skipped') .map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })), permittedSteps: permittedStepKeys({ id: ac.userId, permissions: [...ac.permissions], isSuperAdmin: ac.isSuperAdmin, }), message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库', }); }); function preflightModelPayload( report: PreflightReport, permittedSteps: ImportStepKey[], ): string { const guidance = '预检报告已以卡片展示:请引导用户在卡内确认列映射与策略并点击「生成导入向导」;' + '仅当用户在聊天文本中显式给出确认时才调用 start_import_wizard'; const fullPayload = JSON.stringify({ status: 'success', report, permittedSteps, message: guidance, }); if (fullPayload.length <= 32 * 1024) return fullPayload; return JSON.stringify({ status: 'success', truncated: true, report: { verdict: report.verdict, stages: report.stages.map((stage) => ({ stepKey: stage.stepKey, label: stage.label, sheetNames: stage.sheetNames, total: stage.total, create: stage.create, update: stage.update, error: stage.error, skip: stage.skip, mapping: stage.mapping, missingRequired: stage.missingRequired, })), questions: report.questions, errorSamples: report.errorSamples.slice(0, 10), nextSteps: report.nextSteps, }, permittedSteps, message: guidance, }); } export function compactImportWizard(detail: any): { runId: string; fileName: string; sheets: Array<{ name: string; suggestedStepKey: string | null; headers: string[]; rowCount: number; }>; steps: Array<{ stepKey: string; label: string; sheets: string[]; status: string }>; } { return { runId: detail.id, fileName: detail.fileName, sheets: detail.sheets.map((sheet: any) => ({ name: sheet.name, suggestedStepKey: sheet.suggestedStepKey, headers: sheet.headers, rowCount: sheet.rowCount, })), steps: detail.steps.map((step: any) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets, status: step.status, })), }; }