Files
gongxue-base/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts
wangziqi 9048816abc feat(ai): 移除 Excel 导入预检链路
- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件
- 删除 imports.preflight 解析器与 PreflightReport 类型
- SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard
- 前端同步移除预检类型/组件/测试,保留导入向导
2026-08-06 15:50:38 +08:00

226 lines
7.3 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,
} 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,
})),
};
}