feat(ai-chat): AI 导入确认流程完善并清理代码质量
- 新增导入确认/映射解析辅助,支持预检后生成导入向导 - 抽取 parseAttachmentArgs/beginImportToolRun 等重复逻辑 - 双重类型断言改为运行时守卫,消除 aislop 告警
This commit is contained in:
@@ -20,6 +20,14 @@ describe('AiAttachmentService', () => {
|
||||
expect(detectMimeType(buffer, declared)).toBe(expected);
|
||||
});
|
||||
|
||||
it('detects CSV from the declared MIME type without a binary signature', () => {
|
||||
const detectMimeType = (
|
||||
service as unknown as { detectMimeType(buffer: Buffer, declared: string): string }
|
||||
).detectMimeType.bind(service);
|
||||
expect(detectMimeType(Buffer.from('姓名,学号\n张三,1\n'), 'text/csv')).toBe('text/csv');
|
||||
expect(detectMimeType(Buffer.from('a,b\n1,2\n'), 'application/csv')).toBe('text/csv');
|
||||
});
|
||||
|
||||
it('rejects more than five attachments before repository access', async () => {
|
||||
await expect(service.requireReadyOwned(7, [1, 2, 3, 4, 5, 6])).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -48,6 +56,8 @@ describe('AiAttachmentService', () => {
|
||||
).assertFileExtension.bind(service);
|
||||
expect(() => assertFileExtension('report.exe', 'application/pdf')).toThrow(BadRequestException);
|
||||
expect(() => assertFileExtension('report.pdf', 'application/pdf')).not.toThrow();
|
||||
expect(() => assertFileExtension('students.csv', 'text/csv')).not.toThrow();
|
||||
expect(() => assertFileExtension('students.xlsx', 'text/csv')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('decodes UTF-8 filenames mangled by Latin-1 multipart parsing', () => {
|
||||
|
||||
@@ -20,6 +20,8 @@ const ACCEPTED_MIME_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/csv',
|
||||
'application/csv',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
@@ -210,6 +212,9 @@ export class AiAttachmentService {
|
||||
const result = await mammoth.extractRawText({ buffer });
|
||||
return this.normalizeExtractedText(result.value);
|
||||
}
|
||||
if (mimeType.includes('csv')) {
|
||||
return this.normalizeExtractedText(buffer.toString('utf8').replace(/^\uFEFF/, ''));
|
||||
}
|
||||
if (mimeType.includes('spreadsheetml')) {
|
||||
return this.normalizeExtractedText(await this.excelReader.extractText(buffer));
|
||||
}
|
||||
@@ -235,6 +240,7 @@ export class AiAttachmentService {
|
||||
|
||||
private assertDeclaredType(declared: string, detected: string): void {
|
||||
if (!declared || declared === 'application/octet-stream') return;
|
||||
if (detected.includes('csv') || declared.includes('csv')) return;
|
||||
if (declared !== detected) throw new BadRequestException('附件类型与文件内容不一致');
|
||||
}
|
||||
|
||||
@@ -245,6 +251,8 @@ export class AiAttachmentService {
|
||||
'image/png': ['png'],
|
||||
'image/webp': ['webp'],
|
||||
'application/pdf': ['pdf'],
|
||||
'text/csv': ['csv'],
|
||||
'application/csv': ['csv'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
|
||||
@@ -278,6 +286,7 @@ export class AiAttachmentService {
|
||||
) {
|
||||
return declaredMimeType;
|
||||
}
|
||||
if (/csv/i.test(declaredMimeType)) return 'text/csv';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
@@ -305,6 +314,8 @@ export class AiAttachmentService {
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'application/pdf': 'pdf',
|
||||
'text/csv': 'csv',
|
||||
'application/csv': 'csv',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||
|
||||
@@ -18,7 +18,7 @@ export const A2UI_TOOL_SCHEMAS = [
|
||||
function: {
|
||||
name: 'preflight_import',
|
||||
description:
|
||||
'对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;根据报告向用户确认后,再调用 start_import_wizard。',
|
||||
'对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;预检结果会以可交互卡片展示列映射与策略确认,引导用户在卡内点击「生成导入向导」,无需在聊天里重复确认卡内已覆盖的问题。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -26,6 +26,12 @@ export const A2UI_TOOL_SCHEMAS = [
|
||||
type: 'integer',
|
||||
description: '上传的 Excel 附件 ID。系统直接从文件读取行数据,无需(也不要)在参数里抄录数据。',
|
||||
},
|
||||
headerRow: {
|
||||
type: 'integer',
|
||||
description: '表头所在行(从 1 开始,默认 1)。预检时对整个文件使用该行作为表头。',
|
||||
minimum: 1,
|
||||
maximum: 1000,
|
||||
},
|
||||
},
|
||||
required: ['attachmentId'],
|
||||
additionalProperties: false,
|
||||
@@ -144,70 +150,6 @@ export const A2UI_TOOL_SCHEMAS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'render_review',
|
||||
description:
|
||||
'生成一张“批量导入工作流预览卡”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 后,系统直接解析文件生成行数据(推荐,避免抄录错误),sections 只需给出分表、表名和列映射;无附件时才手工提供 rows。用户确认后系统才会入库。每个回答回合只能调用一次,且只生成一张预览卡:需要导入的多个分表(最多 20 个)必须合并到同一次调用的 sections 里,一次全部给出;同一业务类型可有多张 sheet,每张 sheet 分配唯一 key 并填写正确的 type;生成成功后直接提示用户审阅,可逐表确认、整组确认或一次全部确认,不要重复调用本工具。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: '预览标题(≤50字)', maxLength: 50 },
|
||||
summary: { type: 'string', description: '预览说明(≤500字)', maxLength: 500 },
|
||||
attachmentId: {
|
||||
type: 'integer',
|
||||
description: '上传的 Excel 附件 ID。传入后系统直接从文件读取全部行数据,无需(也不要)在 rows 里抄录数据。',
|
||||
},
|
||||
sections: {
|
||||
type: 'array',
|
||||
description: '分表预览(1-20个)。每张 sheet 的 key 必须是唯一实例 ID(仅字母数字下划线,≤50),type 为业务类型。',
|
||||
minItems: 1,
|
||||
maxItems: 20,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', description: '唯一实例 ID(如 checkins_girls_4、students_building_2),仅字母数字下划线且 ≤50 字符', pattern: '^[a-zA-Z0-9_]{1,50}$' },
|
||||
type: { type: 'string', description: '业务类型:students 学生 / rooms 宿舍 / transfers 换宿 / checkins 入住记录', enum: ['students', 'rooms', 'transfers', 'checkins'] },
|
||||
title: { type: 'string', description: '分表标题(≤50字)', maxLength: 50 },
|
||||
kind: { type: 'string', enum: ['table'], description: '固定为 table' },
|
||||
sheet: { type: 'string', description: '工作表名称(与 Excel 中的 sheet 名一致);省略时使用第一个工作表' },
|
||||
headerRow: { type: 'integer', description: '表头所在行(从 1 开始),默认 1' },
|
||||
columns: {
|
||||
type: 'array',
|
||||
description: '表格列定义(1-30个)。省略 sourceHeader 时系统按表头文字自动识别;给出 sourceHeader 可指定该列在工作表中的原始表头。',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', description: '列标识,仅字母数字下划线', pattern: '^[a-zA-Z0-9_]{1,50}$' },
|
||||
title: { type: 'string', description: '列中文标题(≤50字)', maxLength: 50 },
|
||||
sourceHeader: { type: 'string', description: '工作表中对应的原始表头文字(如 姓名/手机号)', maxLength: 50 },
|
||||
},
|
||||
required: ['key', 'title'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
rows: {
|
||||
type: 'array',
|
||||
description: '行数据(≤500行)。建议键名:学生 name/phone/studentNo/gender/organization;宿舍 roomNumber/capacity/building/floor/roomType;换宿 studentNo 或 studentPhone、oldRoom、newRoom、transferDate(YYYY-MM-DD);入住记录 name/phone 或 studentNo、roomNumber、checkInDate(YYYY-MM-DD)。服务端兼容常见别名。',
|
||||
items: {
|
||||
type: 'object',
|
||||
description: '单元格值仅允许字符串、数字、布尔或 null',
|
||||
additionalProperties: { anyOf: CELL_VALUE_ANY_OF },
|
||||
},
|
||||
},
|
||||
issues: { type: 'array', description: '解析中发现的问题(≤50条)', items: { type: 'string' } },
|
||||
},
|
||||
required: ['key', 'type', 'title', 'kind', 'columns', 'rows'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['title', 'sections'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
@@ -260,10 +202,11 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
|
||||
修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。
|
||||
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
|
||||
1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。
|
||||
2. 报告为 blocked 时,向用户说明阻断原因并建议修正文件后重传,不要生成向导;报告为 needs_input 时,按报告中的 questions 向用户确认:选项型问题用 render_form 生成表单(如更新策略、重复策略、校区、未匹配行处理),列映射类问题用聊天文本确认;报告为 ready 时可直接进入下一步,如需列映射确认也可先问。不要替用户默认做出影响数据的决定。
|
||||
2. 预检报告会以可交互卡片显示给用户:卡内已提供列映射控件和策略控件(更新已有记录、重复行策略、校区、未匹配行处理),并有「生成导入向导」按钮。引导用户在卡内完成确认并点击按钮即可生成向导,不要在聊天里反复确认卡内已覆盖的问题。你只需说明报告结论:blocked 时解释阻断原因并建议修正文件后重传(因缺少列映射而 blocked 时提示在卡内补全映射);needs_input 时说明需要确认的问题并提示在卡内选择;ready 时提示可直接在卡内生成向导。卡内未覆盖的自由输入(如自定义校区)才在聊天中向用户提问。不要替用户默认做出影响数据的决定。
|
||||
报告只给汇总统计时,基于报告中的 errorSamples(工作表与行号、示例值)向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。
|
||||
3. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。
|
||||
3. 仅当用户明确在聊天文本中给出确认(而非使用预检卡)时,才调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。若当前消息已通过预检卡生成向导,不要重复调用。
|
||||
4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。
|
||||
工具结果中的 permittedSteps 表示当前用户可提交的阶段,只引导这些阶段,未列出的阶段不要建议提交或执行。
|
||||
每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。
|
||||
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。
|
||||
上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。
|
||||
@@ -271,6 +214,6 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
|
||||
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。
|
||||
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
||||
- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。
|
||||
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再按报告提问并生成导入向导,按依赖顺序执行。
|
||||
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。
|
||||
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
|
||||
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
EditMessageDto,
|
||||
MessagePageQueryDto,
|
||||
RegenerateMessageDto,
|
||||
ResolveImportPreflightDto,
|
||||
SendMessageDto,
|
||||
SubmitFormDto,
|
||||
SubmitReviewDto,
|
||||
@@ -237,6 +238,23 @@ export class AiChatController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('import/preflight/:messageId/resolve/stream')
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
async resolveImportPreflight(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
@Param('messageId', ParseIntPipe) messageId: number,
|
||||
@Body() dto: ResolveImportPreflightDto,
|
||||
): Promise<void> {
|
||||
const conversationId = await this.service.resolvePreflightConversationId(
|
||||
req.user.id,
|
||||
messageId,
|
||||
);
|
||||
return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) =>
|
||||
this.service.resolveImportPreflight(req.user, messageId, dto, signal, emit, onReady),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/steps/:sectionKey/confirm')
|
||||
async confirmReviewStep(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
|
||||
@@ -78,7 +78,6 @@ export async function executeGeneration(
|
||||
);
|
||||
}
|
||||
tools.push(...A2UI_TOOL_SCHEMAS);
|
||||
tools = tools.filter((tool) => tool.function.name !== 'render_review');
|
||||
const runtimeConfig = await context.configService.getRuntimeConfig();
|
||||
const config = {
|
||||
...runtimeConfig,
|
||||
|
||||
109
apps/server/src/ai-chat/ai-chat.import-confirm.ts
Normal file
109
apps/server/src/ai-chat/ai-chat.import-confirm.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
IMPORT_FIELD_ALIASES,
|
||||
IMPORT_STEP_KEYS,
|
||||
type ColumnMapping,
|
||||
type ImportRunSettings,
|
||||
type ImportStepKey,
|
||||
} from '../imports/imports.types';
|
||||
|
||||
export interface ConfirmedMappingOptions {
|
||||
/** 每个业务阶段允许的表头集合;为空时不校验表头是否存在。 */
|
||||
allowedHeadersByStep?: Partial<Record<ImportStepKey, string[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并校验用户确认的列映射。
|
||||
* 与 start_import_wizard 工具、预检卡 resolve 端点共用,保证两条入口口径一致。
|
||||
*/
|
||||
export function parseConfirmedMapping(
|
||||
raw: unknown,
|
||||
options: ConfirmedMappingOptions = {},
|
||||
): Partial<Record<ImportStepKey, ColumnMapping>> | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new BadRequestException('mapping 参数格式错误');
|
||||
}
|
||||
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
|
||||
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) {
|
||||
throw new BadRequestException(`mapping 包含未知业务类型:${stepKey}`);
|
||||
}
|
||||
if (fields === undefined || fields === null) continue;
|
||||
if (typeof fields !== 'object' || Array.isArray(fields)) {
|
||||
throw new BadRequestException(`mapping 中「${stepKey}」的列映射格式错误`);
|
||||
}
|
||||
const typedStepKey = stepKey as ImportStepKey;
|
||||
const allowedFields = new Set(Object.keys(IMPORT_FIELD_ALIASES[typedStepKey]));
|
||||
const allowedHeaders = new Set(options.allowedHeadersByStep?.[typedStepKey] ?? []);
|
||||
const columnMapping: ColumnMapping = {};
|
||||
for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {
|
||||
if (typeof field !== 'string' || !field.trim() || field.length > 50) continue;
|
||||
if (!allowedFields.has(field)) {
|
||||
throw new BadRequestException(`mapping 中「${stepKey}」包含未知字段:${field}`);
|
||||
}
|
||||
if (typeof header !== 'string' || !header.trim()) continue;
|
||||
const headerName = header.slice(0, 200);
|
||||
if (allowedHeaders.size > 0 && !allowedHeaders.has(headerName)) {
|
||||
throw new BadRequestException(
|
||||
`「${stepKey}」列映射「${headerName}」不在工作表表头中`,
|
||||
);
|
||||
}
|
||||
columnMapping[field] = headerName;
|
||||
}
|
||||
mapping[typedStepKey] = columnMapping;
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/** 从包含顶层 organization/updateExisting/duplicatePolicy/skipUnmatched 的对象解析策略设置。 */
|
||||
export function parseConfirmedSettings(parsedRecord: Record<string, unknown>): ImportRunSettings {
|
||||
const settings: ImportRunSettings = {};
|
||||
if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) {
|
||||
if (typeof parsedRecord.organization !== 'string') {
|
||||
throw new BadRequestException('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 BadRequestException('updateExisting 必须是布尔值');
|
||||
}
|
||||
settings.updateExisting = parsedRecord.updateExisting;
|
||||
}
|
||||
if (parsedRecord.duplicatePolicy !== undefined) {
|
||||
if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') {
|
||||
throw new BadRequestException('duplicatePolicy 只能是 error 或 skip');
|
||||
}
|
||||
settings.duplicatePolicy = parsedRecord.duplicatePolicy;
|
||||
}
|
||||
if (parsedRecord.skipUnmatched !== undefined) {
|
||||
if (typeof parsedRecord.skipUnmatched !== 'boolean') {
|
||||
throw new BadRequestException('skipUnmatched 必须是布尔值');
|
||||
}
|
||||
settings.skipUnmatched = parsedRecord.skipUnmatched;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** 解析 resolve 端点嵌套的 settings 参数(缺省为空对象)。 */
|
||||
export function parseNestedSettings(raw: unknown): ImportRunSettings {
|
||||
if (raw === undefined || raw === null) return {};
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new BadRequestException('settings 参数格式错误');
|
||||
}
|
||||
return parseConfirmedSettings(raw as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export 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)
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,7 +58,9 @@ import {
|
||||
} from './ai-chat.streaming';
|
||||
import {
|
||||
resolveFormConversationId,
|
||||
resolvePreflightConversationId,
|
||||
resolveReviewConversationId,
|
||||
resolveImportPreflight,
|
||||
submitForm,
|
||||
submitReview,
|
||||
confirmReviewStep,
|
||||
@@ -297,10 +299,10 @@ export class AiChatService implements AiChatServiceContext {
|
||||
executePreflightImport(
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
userId: number,
|
||||
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
return executePreflightImport(this, messageId, call, userId, emit);
|
||||
return executePreflightImport(this, messageId, call, context, emit);
|
||||
}
|
||||
|
||||
executeGeneration(input: GenerationInput): Promise<void> {
|
||||
@@ -407,6 +409,10 @@ export class AiChatService implements AiChatServiceContext {
|
||||
return resolveReviewConversationId(this, userId, reviewId);
|
||||
}
|
||||
|
||||
resolvePreflightConversationId(userId: number, messageId: number): Promise<number> {
|
||||
return resolvePreflightConversationId(this, userId, messageId);
|
||||
}
|
||||
|
||||
submitForm(
|
||||
user: AuthenticatedUser,
|
||||
formId: string,
|
||||
@@ -433,6 +439,21 @@ export class AiChatService implements AiChatServiceContext {
|
||||
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
|
||||
}
|
||||
|
||||
resolveImportPreflight(
|
||||
user: AuthenticatedUser,
|
||||
messageId: number,
|
||||
dto: {
|
||||
clientRequestId: string;
|
||||
mapping?: Record<string, unknown>;
|
||||
settings?: Record<string, unknown>;
|
||||
},
|
||||
signal: AbortSignal,
|
||||
emit: AiSseEmitter,
|
||||
onReady: () => void,
|
||||
): Promise<void> {
|
||||
return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady);
|
||||
}
|
||||
|
||||
confirmReviewStep(
|
||||
user: AuthenticatedUser,
|
||||
reviewId: string,
|
||||
|
||||
@@ -13,6 +13,17 @@ import type {
|
||||
} from './ai-chat.types';
|
||||
import { DEFAULT_TITLE, reviewSectionType } from './ai-chat.types';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import type {
|
||||
ImportStageRequest,
|
||||
ImportStepKey,
|
||||
PreflightReport,
|
||||
} from '../imports/imports.types';
|
||||
import {
|
||||
isExcelAttachment,
|
||||
parseConfirmedMapping,
|
||||
parseNestedSettings,
|
||||
} from './ai-chat.import-confirm';
|
||||
import { compactImportWizard } from './ai-chat.tool-actions';
|
||||
|
||||
export async function resolveFormConversationId(
|
||||
context: AiChatServiceContext,
|
||||
@@ -32,6 +43,193 @@ export async function resolveReviewConversationId(
|
||||
return review.conversationId;
|
||||
}
|
||||
|
||||
export async function resolvePreflightConversationId(
|
||||
context: AiChatServiceContext,
|
||||
userId: number,
|
||||
messageId: number,
|
||||
): Promise<number> {
|
||||
const message = await context.messages.findOne({
|
||||
where: { id: messageId },
|
||||
relations: { conversation: true },
|
||||
});
|
||||
if (!message) throw new NotFoundException('消息不存在');
|
||||
if (message.role !== 'assistant') {
|
||||
throw new BadRequestException('该消息不是助手消息,无法确认导入预检');
|
||||
}
|
||||
const conversation = await context.requireOwnedConversation(userId, message.conversation.id);
|
||||
return conversation.id;
|
||||
}
|
||||
|
||||
function readPreflightCard(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): PreflightReport {
|
||||
const card = metadata?.a2uiImportPreflight;
|
||||
if (!card || typeof card !== 'object' || Array.isArray(card)) {
|
||||
throw new BadRequestException('预检报告不存在或已失效');
|
||||
}
|
||||
if (!isPreflightReport(card)) {
|
||||
throw new BadRequestException('预检报告格式异常');
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function isPreflightReport(value: object): value is PreflightReport {
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.verdict === 'string' &&
|
||||
Array.isArray(record.stages) &&
|
||||
Array.isArray(record.blocks) &&
|
||||
Array.isArray(record.questions) &&
|
||||
Array.isArray(record.nextSteps) &&
|
||||
Array.isArray(record.errorSamples)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadReviewForConfirm(
|
||||
context: AiChatServiceContext,
|
||||
user: AuthenticatedUser,
|
||||
reviewId: string,
|
||||
): Promise<AiReview> {
|
||||
const review = await context.reviewService.findOwned(reviewId, user.id);
|
||||
if (review.status === 'submitted') {
|
||||
throw new ConflictException('导入已全部确认,无需重复确认');
|
||||
}
|
||||
if (review.status === 'expired') {
|
||||
throw new ConflictException('导入预览已失效,请重新生成预览');
|
||||
}
|
||||
return review;
|
||||
}
|
||||
|
||||
async function finalizeReview(
|
||||
context: AiChatServiceContext,
|
||||
updated: AiReview,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await context.markReviewSubmittedOnMessage(
|
||||
updated.assistantMessageId,
|
||||
updated.conversationId,
|
||||
updated,
|
||||
);
|
||||
return context.reviewService.serialize(updated);
|
||||
}
|
||||
|
||||
async function logImportOp(
|
||||
context: AiChatServiceContext,
|
||||
user: AuthenticatedUser,
|
||||
action: string,
|
||||
detail: string,
|
||||
): Promise<void> {
|
||||
await context.opLog?.log({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
module: '批量导入',
|
||||
action,
|
||||
detail,
|
||||
targetType: 'ai_review',
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveImportPreflight(
|
||||
context: AiChatServiceContext,
|
||||
user: AuthenticatedUser,
|
||||
messageId: number,
|
||||
dto: {
|
||||
clientRequestId: string;
|
||||
mapping?: Record<string, unknown>;
|
||||
settings?: Record<string, unknown>;
|
||||
},
|
||||
signal: AbortSignal,
|
||||
emit: AiSseEmitter,
|
||||
onReady: () => void,
|
||||
): Promise<void> {
|
||||
context.throwIfAborted(signal);
|
||||
const message = await context.messages.findOne({ where: { id: messageId } });
|
||||
if (!message) throw new NotFoundException('消息不存在');
|
||||
if (message.role !== 'assistant') {
|
||||
throw new BadRequestException('该消息不是助手消息,无法确认导入预检');
|
||||
}
|
||||
const conversation = await context.requireOwnedConversation(user.id, message.conversationId);
|
||||
const preflight = readPreflightCard(message.metadata);
|
||||
|
||||
await context.acquireConversation(conversation.id);
|
||||
try {
|
||||
context.throwIfAborted(signal);
|
||||
const existingWizard = message.metadata?.a2uiImportWizard;
|
||||
if (existingWizard && typeof existingWizard === 'object' && !Array.isArray(existingWizard)) {
|
||||
onReady();
|
||||
emit('ui.import_preflight', {
|
||||
messageId,
|
||||
preflight: { ...preflight, resolved: true },
|
||||
});
|
||||
emit('ui.import_wizard', { messageId, wizard: existingWizard });
|
||||
return;
|
||||
}
|
||||
|
||||
const attachmentId = preflight.attachmentId;
|
||||
const headerRow = preflight.headerRow ?? 1;
|
||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
||||
throw new BadRequestException('预检报告缺少附件信息,请重新预检');
|
||||
}
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(user.id, [
|
||||
attachmentId as number,
|
||||
]);
|
||||
if (!isExcelAttachment(attachment)) {
|
||||
throw new BadRequestException('附件不是 Excel 文件,无法生成导入向导');
|
||||
}
|
||||
const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({
|
||||
stepKey: stage.stepKey,
|
||||
sheet: stage.sheetNames[0],
|
||||
headerRow,
|
||||
}));
|
||||
if (stages.some((stage) => !stage.sheet || !String(stage.sheet).trim())) {
|
||||
throw new BadRequestException('预检报告缺少工作表信息,请重新预检');
|
||||
}
|
||||
const allowedHeadersByStep: Partial<Record<ImportStepKey, string[]>> = {};
|
||||
for (const stage of preflight.stages) {
|
||||
allowedHeadersByStep[stage.stepKey] = stage.headers ?? [];
|
||||
}
|
||||
const mapping = parseConfirmedMapping(dto.mapping, { allowedHeadersByStep });
|
||||
const settings = parseNestedSettings(dto.settings);
|
||||
if (!context.importsService) throw new BadRequestException('导入向导服务未配置');
|
||||
|
||||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
||||
const detail = await context.importsService.createRun(
|
||||
{
|
||||
id: user.id,
|
||||
permissions: [...user.permissions],
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
},
|
||||
'ai',
|
||||
{
|
||||
originalName: attachment.originalName,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
buffer,
|
||||
},
|
||||
conversation.id,
|
||||
stages,
|
||||
mapping,
|
||||
settings,
|
||||
);
|
||||
const wizard = compactImportWizard(detail);
|
||||
message.metadata = {
|
||||
...message.metadata,
|
||||
a2uiImportPreflight: { ...preflight, resolved: true, runId: detail.id },
|
||||
a2uiImportWizard: wizard,
|
||||
};
|
||||
await context.messages.save(message);
|
||||
|
||||
onReady();
|
||||
emit('ui.import_preflight', {
|
||||
messageId,
|
||||
preflight: { ...preflight, resolved: true, runId: detail.id },
|
||||
});
|
||||
emit('ui.import_wizard', { messageId, wizard });
|
||||
} finally {
|
||||
context.activeConversations.delete(conversation.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertReviewImportPermissions(
|
||||
context: AiChatServiceContext,
|
||||
user: AuthenticatedUser,
|
||||
@@ -205,34 +403,20 @@ export async function confirmReviewStep(
|
||||
reviewId: string,
|
||||
sectionKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const review = await context.reviewService.findOwned(reviewId, user.id);
|
||||
if (review.status === 'submitted') {
|
||||
throw new ConflictException('导入已全部确认,无需重复确认');
|
||||
}
|
||||
if (review.status === 'expired') {
|
||||
throw new ConflictException('导入预览已失效,请重新生成预览');
|
||||
}
|
||||
const review = await loadReviewForConfirm(context, user, reviewId);
|
||||
assertReviewImportPermissions(context, user, review, sectionKey);
|
||||
const { review: updated, message } = await context.reviewService.submitSection(
|
||||
review.id,
|
||||
user.id,
|
||||
sectionKey,
|
||||
);
|
||||
await context.opLog?.log({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
module: '批量导入',
|
||||
action: '确认导入分表',
|
||||
detail: `「${review.title}」分表「${sectionKey}」:${message}`,
|
||||
targetType: 'ai_review',
|
||||
status: 'success',
|
||||
});
|
||||
await context.markReviewSubmittedOnMessage(
|
||||
updated.assistantMessageId,
|
||||
updated.conversationId,
|
||||
updated,
|
||||
await logImportOp(
|
||||
context,
|
||||
user,
|
||||
'确认导入分表',
|
||||
`「${review.title}」分表「${sectionKey}」:${message}`,
|
||||
);
|
||||
return context.reviewService.serialize(updated);
|
||||
return finalizeReview(context, updated);
|
||||
}
|
||||
|
||||
export async function confirmReviewGroup(
|
||||
@@ -244,13 +428,7 @@ export async function confirmReviewGroup(
|
||||
if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') {
|
||||
throw new BadRequestException(`业务类型不支持: ${String(type)}`);
|
||||
}
|
||||
const review = await context.reviewService.findOwned(reviewId, user.id);
|
||||
if (review.status === 'submitted') {
|
||||
throw new ConflictException('导入已全部确认,无需重复确认');
|
||||
}
|
||||
if (review.status === 'expired') {
|
||||
throw new ConflictException('导入预览已失效,请重新生成预览');
|
||||
}
|
||||
const review = await loadReviewForConfirm(context, user, reviewId);
|
||||
assertReviewImportPermissions(context, user, review, undefined, type);
|
||||
const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);
|
||||
const sectionTitles = context.reviewService
|
||||
@@ -258,21 +436,13 @@ export async function confirmReviewGroup(
|
||||
.filter((section) => section.type === type)
|
||||
.map((section) => section.title)
|
||||
.join('、');
|
||||
await context.opLog?.log({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
module: '批量导入',
|
||||
action: '确认导入分组',
|
||||
detail: `「${review.title}」分组「${type}」:${sectionTitles}`,
|
||||
targetType: 'ai_review',
|
||||
status: 'success',
|
||||
});
|
||||
await context.markReviewSubmittedOnMessage(
|
||||
updated.assistantMessageId,
|
||||
updated.conversationId,
|
||||
updated,
|
||||
await logImportOp(
|
||||
context,
|
||||
user,
|
||||
'确认导入分组',
|
||||
`「${review.title}」分组「${type}」:${sectionTitles}`,
|
||||
);
|
||||
return context.reviewService.serialize(updated);
|
||||
return finalizeReview(context, updated);
|
||||
}
|
||||
|
||||
export function a2uiSubmitInfo(
|
||||
|
||||
@@ -1,54 +1,76 @@
|
||||
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 { permittedStepKeys } from '../imports/imports.access';
|
||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||
import type { AgentToolContext } from './ai-chat.tools';
|
||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||
import {
|
||||
isExcelAttachment,
|
||||
parseConfirmedMapping,
|
||||
parseConfirmedSettings,
|
||||
} from './ai-chat.import-confirm';
|
||||
|
||||
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)
|
||||
);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executePreflightImport(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
userId: number,
|
||||
agentContext: AgentToolContext,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||||
toolName: 'preflight_import',
|
||||
skillKey: null,
|
||||
argumentsData: null,
|
||||
});
|
||||
const { run, parsedArgs, startedAt } = await beginImportToolRun(
|
||||
context,
|
||||
messageId,
|
||||
call,
|
||||
emit,
|
||||
'preflight_import',
|
||||
);
|
||||
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
const attachmentId =
|
||||
typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined;
|
||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
||||
throw new Error('缺少附件 attachmentId');
|
||||
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(userId, [
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [
|
||||
attachmentId as number,
|
||||
]);
|
||||
if (!isExcelAttachment(attachment)) {
|
||||
@@ -61,10 +83,23 @@ export async function executePreflightImport(
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
buffer,
|
||||
}, headerRow);
|
||||
const permittedSteps = permittedStepKeys({
|
||||
id: agentContext.userId,
|
||||
permissions: [...agentContext.permissions],
|
||||
isSuperAdmin: agentContext.isSuperAdmin,
|
||||
});
|
||||
const preflightCard: PreflightReport = {
|
||||
...preflight,
|
||||
attachmentId: attachment.id,
|
||||
headerRow,
|
||||
permittedSteps,
|
||||
resolved: false,
|
||||
runId: null,
|
||||
};
|
||||
assistant.metadata = {
|
||||
...assistant.metadata,
|
||||
a2uiImportPreflight: preflight,
|
||||
a2uiImportPreflight: preflightCard,
|
||||
};
|
||||
await context.messages.save(assistant);
|
||||
|
||||
@@ -74,12 +109,8 @@ export async function executePreflightImport(
|
||||
.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',
|
||||
});
|
||||
emit('ui.import_preflight', { messageId, preflight: preflightCard });
|
||||
return preflightModelPayload(preflight, permittedSteps);
|
||||
} catch (error) {
|
||||
const summary =
|
||||
error instanceof Error ? error.message.slice(0, 100) : '导入预检失败';
|
||||
@@ -99,24 +130,18 @@ export async function executeStartImportWizard(
|
||||
agentContext: AgentToolContext,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||||
toolName: 'start_import_wizard',
|
||||
skillKey: null,
|
||||
argumentsData: null,
|
||||
});
|
||||
const { run, parsedArgs, startedAt } = await beginImportToolRun(
|
||||
context,
|
||||
messageId,
|
||||
call,
|
||||
emit,
|
||||
'start_import_wizard',
|
||||
);
|
||||
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
const attachmentId =
|
||||
typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined;
|
||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
||||
throw new Error('缺少附件 attachmentId');
|
||||
}
|
||||
const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs);
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [
|
||||
attachmentId as number,
|
||||
]);
|
||||
@@ -132,6 +157,12 @@ export async function executeStartImportWizard(
|
||||
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);
|
||||
@@ -156,8 +187,18 @@ export async function executeStartImportWizard(
|
||||
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);
|
||||
@@ -169,6 +210,16 @@ export async function executeStartImportWizard(
|
||||
.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',
|
||||
@@ -176,6 +227,11 @@ export async function executeStartImportWizard(
|
||||
steps: detail.steps
|
||||
.filter((step) => step.status !== 'skipped')
|
||||
.map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })),
|
||||
permittedSteps: permittedStepKeys({
|
||||
id: agentContext.userId,
|
||||
permissions: [...agentContext.permissions],
|
||||
isSuperAdmin: agentContext.isSuperAdmin,
|
||||
}),
|
||||
message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库',
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -185,57 +241,44 @@ export async function executeStartImportWizard(
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfirmedMapping(raw: unknown): Partial<Record<ImportStepKey, ColumnMapping>> | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误');
|
||||
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
|
||||
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
|
||||
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<string, unknown>)) {
|
||||
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<string, unknown>): 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;
|
||||
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): {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
executePreflightImport,
|
||||
executeRenderChart,
|
||||
executeRenderForm,
|
||||
executeRenderReview,
|
||||
executeStartImportWizard,
|
||||
} from './ai-chat.tool-actions';
|
||||
|
||||
@@ -91,17 +90,11 @@ export async function executeTool(
|
||||
return executeRenderForm(context, messageId, call, userId, emit);
|
||||
}
|
||||
if (call.name === 'preflight_import') {
|
||||
return executePreflightImport(context, messageId, call, userId, emit);
|
||||
return executePreflightImport(context, messageId, call, agentContext, emit);
|
||||
}
|
||||
if (call.name === 'start_import_wizard') {
|
||||
return executeStartImportWizard(context, messageId, call, agentContext, emit);
|
||||
}
|
||||
if (call.name === 'render_review') {
|
||||
if (reviewSubmitted) {
|
||||
return denyTool(context, messageId, call, 'render_review', '导入已确认,无需再次生成预览', '导入已确认', emit);
|
||||
}
|
||||
return executeRenderReview(context, messageId, call, userId, emit);
|
||||
}
|
||||
if (call.name === 'render_chart') {
|
||||
return executeRenderChart(context, messageId, call, emit);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
import { readXlsxSheetsFallback } from '../imports/imports.workbook-fallback';
|
||||
|
||||
export interface ExcelSheetInfo {
|
||||
name: string;
|
||||
@@ -100,121 +100,19 @@ export class AiExcelReaderService {
|
||||
}
|
||||
|
||||
private async loadWithFallback(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const readEntry = async (name: string): Promise<string | null> => {
|
||||
const entry = zip.file(name);
|
||||
return entry ? entry.async('string') : null;
|
||||
};
|
||||
const workbookXml = await readEntry('xl/workbook.xml');
|
||||
if (!workbookXml) throw new Error('workbook.xml missing');
|
||||
const stripPrefixes = (value: string): string =>
|
||||
value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? '');
|
||||
const relTargets = new Map<string, string>();
|
||||
for (const match of relsXml.matchAll(
|
||||
/<Relationship[^>]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g,
|
||||
)) {
|
||||
const target = match[2].replace(/^\/+/, '');
|
||||
relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`);
|
||||
}
|
||||
|
||||
const sharedStrings = await this.parseSharedStringsFallback(readEntry);
|
||||
const sheets: ExcelSheetRows[] = [];
|
||||
const cleanWorkbook = stripPrefixes(workbookXml);
|
||||
for (const match of cleanWorkbook.matchAll(/<sheet\b[^>]*\/?>/g)) {
|
||||
const tag = match[0].replace(/<sheet\b/, '<sheet').replace(/\/?>$/, '>');
|
||||
const name = tag.match(/\bname="([^"]+)"/)?.[1];
|
||||
const rid = tag.match(/\br:id="([^"]+)"/)?.[1];
|
||||
if (!name || !rid) continue;
|
||||
const target = relTargets.get(rid);
|
||||
const sheetXml = target ? await readEntry(target) : null;
|
||||
if (!sheetXml) continue;
|
||||
sheets.push({
|
||||
name: this.unescapeXml(name),
|
||||
rows: this.sheetRowsFromXmlFallback(sheetXml, sharedStrings),
|
||||
});
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
|
||||
private async parseSharedStringsFallback(
|
||||
readEntry: (name: string) => Promise<string | null>,
|
||||
): Promise<string[]> {
|
||||
const xml = await readEntry('xl/sharedStrings.xml');
|
||||
if (!xml) return [];
|
||||
const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
const strings: string[] = [];
|
||||
for (const match of clean.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/gs)) {
|
||||
const texts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
||||
this.unescapeXml(part[1]),
|
||||
);
|
||||
strings.push(texts.join(''));
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
private sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] {
|
||||
const rows: string[][] = [];
|
||||
const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {
|
||||
const cells = new Map<number, string>();
|
||||
let maxColumn = -1;
|
||||
for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\s\S]*?)<\/c>/gs)) {
|
||||
const attrs = cellMatch[1];
|
||||
const refMatch = attrs.match(/\br="([A-Z]+)\d+"/);
|
||||
const column = refMatch ? this.columnIndex(refMatch[1]) : -1;
|
||||
const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n';
|
||||
const body = cellMatch[2] ?? '';
|
||||
let value = '';
|
||||
if (type === 's') {
|
||||
const index = Number(body.match(/<v>([^<]*)<\/v>/)?.[1] ?? '');
|
||||
value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
|
||||
} else if (type === 'inlineStr') {
|
||||
const texts = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
||||
this.unescapeXml(part[1]),
|
||||
);
|
||||
value = texts.join('');
|
||||
} else {
|
||||
value = this.unescapeXml(body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? '');
|
||||
if (type === 'b') value = value === '1' ? 'true' : 'false';
|
||||
}
|
||||
if (column >= 0) {
|
||||
cells.set(column, value);
|
||||
maxColumn = Math.max(maxColumn, column);
|
||||
}
|
||||
}
|
||||
if (maxColumn < 0) continue;
|
||||
const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? '');
|
||||
if (values.every((value) => value === '')) continue;
|
||||
rows.push(values);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private columnIndex(letters: string): number {
|
||||
let index = 0;
|
||||
for (const char of letters.toUpperCase()) {
|
||||
index = index * 26 + (char.charCodeAt(0) - 64);
|
||||
}
|
||||
return index - 1;
|
||||
}
|
||||
|
||||
private unescapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) =>
|
||||
String.fromCodePoint(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec)));
|
||||
const sheets = await readXlsxSheetsFallback(buffer);
|
||||
return sheets.map((sheet) => ({ name: sheet.name, rows: sheet.rows }));
|
||||
}
|
||||
|
||||
private stringifyCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return '';
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,19 @@ export class SubmitReviewDto {
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class ResolveImportPreflightDto {
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
mapping?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class MessagePageQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
|
||||
Reference in New Issue
Block a user