Files
gongxue-base/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts
wangziqi 824c33a71c refactor(ai-chat): 拆分超大文件并通过 aislop 100 分门槛
- AiChatService 拆出抽象基类,职责不变
- submissions 按提交内容/评审确认/运行时/流程拆为 4 个模块并保持导出兼容
- 导入工具执行器抽取共享 runner,消除重复签名块
- 全仓 aislop 扫描 100/100,0 警告
2026-08-06 12:13:37 +08:00

326 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>; attachmentId: number } {
const parsedRecord =
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
? (parsedArgs as Record<string, unknown>)
: {};
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<ReturnType<typeof beginImportToolRun>>['run'];
parsedArgs: Awaited<ReturnType<typeof beginImportToolRun>>['parsedArgs'];
startedAt: Awaited<ReturnType<typeof beginImportToolRun>>['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<string>,
): Promise<string> {
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<string>;
function makeImportToolExecutor(
toolName: 'preflight_import' | 'start_import_wizard',
handler: (tool: ImportToolContext) => Promise<string>,
): 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<string, unknown>),
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<string, unknown>),
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,
})),
};
}