- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件 - 删除 imports.preflight 解析器与 PreflightReport 类型 - SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard - 前端同步移除预检类型/组件/测试,保留导入向导
226 lines
7.3 KiB
TypeScript
226 lines
7.3 KiB
TypeScript
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<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: '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 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,
|
||
})),
|
||
};
|
||
}
|