import { AiReview } from './entities/ai-review.entity'; import { IMPORT_STEP_KEYS, type ColumnMapping, type ImportRunSettings, type ImportStageRequest, type ImportStepKey, type PreflightReport, } from '../imports/imports.types'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; import { finishToolRun, startToolRun } from './ai-chat.tools'; function isExcelAttachment(attachment: { mimeType: string; originalName: string; }): boolean { return ( attachment.mimeType.includes('spreadsheetml') || attachment.mimeType.includes('excel') || attachment.mimeType.includes('csv') || /\.(xlsx|csv)$/i.test(attachment.originalName) ); } export async function executePreflightImport( context: AiChatServiceContext, messageId: number, call: ModelToolCall, userId: number, emit: AiSseEmitter, ): Promise { const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { toolName: 'preflight_import', skillKey: null, argumentsData: null, }); try { const assistant = await context.messages.findOne({ where: { id: messageId } }); if (!assistant) throw new Error('assistant message missing'); const parsedRecord = parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) ? (parsedArgs as Record) : {}; const attachmentId = typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined; if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { throw new Error('缺少附件 attachmentId'); } const [attachment] = await context.attachmentService.requireReadyOwned(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, }); assistant.metadata = { ...assistant.metadata, a2uiImportPreflight: preflight, }; 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 }); return JSON.stringify({ status: 'success', report: preflight, message: '预检报告已生成,请按报告中的 questions 向用户确认后,再调用 start_import_wizard', }); } catch (error) { const summary = error instanceof Error ? error.message.slice(0, 100) : '导入预检失败'; await finishToolRun(context, run, call, startedAt, { status: 'failed', summary, error: summary, }, emit); return JSON.stringify({ status: 'failed', error: run.resultSummary }); } } export async function executeStartImportWizard( context: AiChatServiceContext, messageId: number, call: ModelToolCall, agentContext: AgentToolContext, emit: AiSseEmitter, ): Promise { const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { toolName: 'start_import_wizard', skillKey: null, argumentsData: null, }); try { const assistant = await context.messages.findOne({ where: { id: messageId } }); if (!assistant) throw new Error('assistant message missing'); const parsedRecord = parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) ? (parsedArgs as Record) : {}; const attachmentId = typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined; if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { throw new Error('缺少附件 attachmentId'); } const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.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 名`); } } 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: agentContext.userId, permissions: [...agentContext.permissions], isSuperAdmin: agentContext.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 }); 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 })), message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库', }); } catch (error) { const summary = error instanceof Error ? error.message.slice(0, 100) : '生成导入向导失败'; await finishToolRun(context, run, call, startedAt, { status: 'failed', summary, error: summary }, emit); return JSON.stringify({ status: 'failed', error: run.resultSummary }); } } function parseConfirmedMapping(raw: unknown): Partial> | undefined { if (raw === undefined || raw === null) return undefined; if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误'); const mapping: Partial> = {}; for (const [stepKey, fields] of Object.entries(raw as Record)) { if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) { throw new Error(`mapping 包含未知业务类型:${stepKey}`); } if (fields === undefined || fields === null) continue; if (typeof fields !== 'object' || Array.isArray(fields)) { throw new Error(`mapping 中「${stepKey}」的列映射格式错误`); } const columnMapping: ColumnMapping = {}; for (const [field, header] of Object.entries(fields as Record)) { if (typeof field !== 'string' || !field.trim() || field.length > 50) continue; if (typeof header !== 'string' || !header.trim()) continue; columnMapping[field] = header.slice(0, 200); } mapping[stepKey as ImportStepKey] = columnMapping; } return mapping; } function parseConfirmedSettings(parsedRecord: Record): ImportRunSettings { const settings: ImportRunSettings = {}; if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) { if (typeof parsedRecord.organization !== 'string') { throw new Error('organization 必须是字符串'); } const organization = parsedRecord.organization.trim().slice(0, 100); if (organization) settings.organization = organization; } if (parsedRecord.updateExisting !== undefined) { if (typeof parsedRecord.updateExisting !== 'boolean') { throw new Error('updateExisting 必须是布尔值'); } settings.updateExisting = parsedRecord.updateExisting; } if (parsedRecord.duplicatePolicy !== undefined) { if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') { throw new Error('duplicatePolicy 只能是 error 或 skip'); } settings.duplicatePolicy = parsedRecord.duplicatePolicy; } if (parsedRecord.skipUnmatched !== undefined) { if (typeof parsedRecord.skipUnmatched !== 'boolean') { throw new Error('skipUnmatched 必须是布尔值'); } settings.skipUnmatched = parsedRecord.skipUnmatched; } return settings; } 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, })), }; } export async function executeRenderForm( context: AiChatServiceContext, messageId: number, call: ModelToolCall, userId: number, emit: AiSseEmitter, ): Promise { const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { toolName: 'render_form', skillKey: null, }); try { const assistant = await context.messages.findOne({ where: { id: messageId } }); if (!assistant) throw new Error('assistant message missing'); const form = await context.formService.createForm( { userId, conversationId: assistant.conversationId, assistantMessageId: messageId }, parsedArgs, ); assistant.metadata = { ...assistant.metadata, a2uiForm: context.formService.serialize(form), }; await context.messages.save(assistant); await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成表单,等待用户填写' }, emit); emit('ui.form', { messageId, form: context.formService.serialize(form), }); return JSON.stringify({ status: 'success', formId: form.id, message: '表单已显示给用户,请提示用户填写并提交', }); } catch { await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '表单参数无效', error: '表单参数无效' }, emit); return JSON.stringify({ status: 'failed', error: '表单参数无效' }); } } export async function executeRenderReview( context: AiChatServiceContext, messageId: number, call: ModelToolCall, userId: number, emit: AiSseEmitter, ): Promise { const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { toolName: 'render_review', skillKey: null, argumentsData: null, }); try { const existingReview = await context.reviewService.findPendingByAssistantMessage(messageId); if (existingReview) { const denial = `本回合已生成导入预览《${existingReview.title}》,请直接提示用户审阅并确认,不要再次调用 render_review;如需多个分表,应全部合并到同一张预览卡。`; await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: denial, error: denial }, emit); return JSON.stringify({ status: 'failed', error: denial }); } const assistant = await context.messages.findOne({ where: { id: messageId } }); if (!assistant) throw new Error('assistant message missing'); const parsedRecord = parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) ? (parsedArgs as Record) : {}; const attachmentId = typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined; let review: AiReview; if (Number.isInteger(attachmentId) && (attachmentId as number) > 0) { const [attachment] = await context.attachmentService.requireReadyOwned(userId, [ attachmentId as number, ]); if ( !attachment.mimeType.includes('spreadsheetml') && !attachment.mimeType.includes('excel') && !attachment.mimeType.includes('csv') ) { throw new Error('附件不是 Excel 文件,无法生成导入预览'); } if (!context.excelReader) throw new Error('Excel 解析器未配置'); const buffer = await context.attachmentService.readStoredBuffer(attachment); const sheets = await context.excelReader.loadSheets(buffer); const sections = await context.reviewService.buildSectionsFromWorkbook(sheets, parsedArgs); review = await context.reviewService.createReview( { userId, conversationId: assistant.conversationId, assistantMessageId: messageId }, { title: parsedRecord.title, summary: parsedRecord.summary ?? null, sections }, ); } else { review = await context.reviewService.createReview( { userId, conversationId: assistant.conversationId, assistantMessageId: messageId }, parsedArgs, ); } const expiredReviews = await context.reviewService.expirePreviousReviews( userId, assistant.conversationId, review.id, ); await Promise.all( expiredReviews.map(async (expired) => { const oldAssistant = await context.messages.findOne({ where: { id: expired.assistantMessageId, conversationId: assistant.conversationId }, }); const oldA2ui = oldAssistant?.metadata?.a2uiReview; if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) { oldAssistant.metadata = { ...oldAssistant.metadata, a2uiReview: context.reviewService.serialize(expired), }; await context.messages.save(oldAssistant); } emit('ui.review', { messageId: expired.assistantMessageId, review: context.reviewService.serialize(expired), }); }), ); assistant.metadata = { ...assistant.metadata, a2uiReview: context.reviewService.serialize(review), }; await context.messages.save(assistant); await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成导入预览,等待用户确认' }, emit); emit('ui.review', { messageId, review: context.reviewService.serialize(review), }); return JSON.stringify({ status: 'success', reviewId: review.id, message: '导入预览已显示给用户,请提示用户审阅并确认', }); } catch (reason) { const errorMessage = reason instanceof Error && reason.message ? reason.message.slice(0, 120) : '导入预览参数无效'; await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: errorMessage, error: errorMessage }, emit); return JSON.stringify({ status: 'failed', error: errorMessage }); } } export async function executeRenderChart( context: AiChatServiceContext, messageId: number, call: ModelToolCall, emit: AiSseEmitter, ): Promise { const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { toolName: 'render_chart', skillKey: null, argumentsData: null, }); try { const assistant = await context.messages.findOne({ where: { id: messageId } }); if (!assistant) throw new Error('assistant message missing'); const chart = context.chartService.createChart(parsedArgs); const existingCharts = assistant.metadata?.a2uiChart; const charts = Array.isArray(existingCharts) ? [...existingCharts] : existingCharts ? [existingCharts] : []; charts.push(context.chartService.serialize(chart)); assistant.metadata = { ...assistant.metadata, a2uiChart: charts, }; await context.messages.save(assistant); await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成图表' }, emit); emit('ui.chart', { messageId, chart: context.chartService.serialize(chart), }); return JSON.stringify({ status: 'success', chartId: chart.id, message: '图表已显示给用户', }); } catch { await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '图表参数无效', error: '图表参数无效' }, emit); return JSON.stringify({ status: 'failed', error: '图表参数无效' }); } }