import { IMPORT_STEP_KEYS, type ImportStageRequest, type ImportStepKey, } from '../imports/imports.types'; import { permittedStepKeys } from '../imports/imports.access'; import { expandStageSheets } from '../imports/imports.mapping'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; import { buildA2uiArtifact } from './ai-a2ui.artifact'; 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: 'start_import_wizard', handler: (tool: ImportToolContext) => Promise, ): ImportToolExecutor { return (context, messageId, call, agentContext, emit) => runImportTool(context, messageId, call, agentContext, emit, toolName, handler); } 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 (expandStageSheets(stage).length === 0) { throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet/sheets,请指定 Excel 中对应的表名`); } 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); assistant.metadata = { ...assistant.metadata, 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); emit('ui.import_wizard', { messageId, wizard }); emit('ui.artifact', { messageId, artifact: buildA2uiArtifact({ type: 'import_wizard', id: `wizard-${detail.id}`, status: 'pending', messageId, conversationId: assistant.conversationId, payload: 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: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库', }); }); 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, })), }; }