refactor(ai-chat): 拆分超大文件并通过 aislop 100 分门槛
- AiChatService 拆出抽象基类,职责不变 - submissions 按提交内容/评审确认/运行时/流程拆为 4 个模块并保持导出兼容 - 导入工具执行器抽取共享 runner,消除重复签名块 - 全仓 aislop 扫描 100/100,0 警告
This commit is contained in:
325
apps/server/src/ai-chat/ai-chat.tool-actions.import.ts
Normal file
325
apps/server/src/ai-chat/ai-chat.tool-actions.import.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user