- 删除 excel_analyze/office_analyze 两个 A2UI 工具(schema、分发、执行器、测试) - 附件分析改为依赖上传时自动提取的文本与 preflight_import 报告 errorSamples - 移除 @officecli/officecli 依赖及 OfficeCliService(PPT 上传不再自动提取文本) - 保留 AiExcelReaderService(附件文本提取与导入预检仍使用)
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
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 type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||
import type { AgentToolContext } from './ai-chat.tools';
|
||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||
|
||
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)
|
||
);
|
||
}
|
||
|
||
export async function executePreflightImport(
|
||
context: AiChatServiceContext,
|
||
messageId: number,
|
||
call: ModelToolCall,
|
||
userId: number,
|
||
emit: AiSseEmitter,
|
||
): Promise<string> {
|
||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||
toolName: 'preflight_import',
|
||
skillKey: null,
|
||
argumentsData: null,
|
||
});
|
||
|
||
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 [attachment] = await context.attachmentService.requireReadyOwned(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,
|
||
});
|
||
assistant.metadata = {
|
||
...assistant.metadata,
|
||
a2uiImportPreflight: preflight,
|
||
};
|
||
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 });
|
||
return JSON.stringify({
|
||
status: 'success',
|
||
report: preflight,
|
||
message: '预检报告已生成,请按报告中的 questions 向用户确认后,再调用 start_import_wizard',
|
||
});
|
||
} catch (error) {
|
||
const summary =
|
||
error instanceof Error ? error.message.slice(0, 100) : '导入预检失败';
|
||
await finishToolRun(context, run, call, startedAt, {
|
||
status: 'failed',
|
||
summary,
|
||
error: summary,
|
||
}, emit);
|
||
return JSON.stringify({ status: 'failed', error: run.resultSummary });
|
||
}
|
||
}
|
||
|
||
export async function executeStartImportWizard(
|
||
context: AiChatServiceContext,
|
||
messageId: number,
|
||
call: ModelToolCall,
|
||
agentContext: AgentToolContext,
|
||
emit: AiSseEmitter,
|
||
): Promise<string> {
|
||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||
toolName: 'start_import_wizard',
|
||
skillKey: null,
|
||
argumentsData: null,
|
||
});
|
||
|
||
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 [attachment] = await context.attachmentService.requireReadyOwned(agentContext.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 名`);
|
||
}
|
||
}
|
||
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: agentContext.userId,
|
||
permissions: [...agentContext.permissions],
|
||
isSuperAdmin: agentContext.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 });
|
||
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 })),
|
||
message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库',
|
||
});
|
||
} catch (error) {
|
||
const summary = error instanceof Error ? error.message.slice(0, 100) : '生成导入向导失败';
|
||
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary, error: summary }, emit);
|
||
return JSON.stringify({ status: 'failed', error: run.resultSummary });
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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,
|
||
})),
|
||
};
|
||
}
|
||
|
||
export async function executeRenderForm(
|
||
context: AiChatServiceContext,
|
||
messageId: number,
|
||
call: ModelToolCall,
|
||
userId: number,
|
||
emit: AiSseEmitter,
|
||
): Promise<string> {
|
||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||
toolName: 'render_form',
|
||
skillKey: null,
|
||
});
|
||
|
||
try {
|
||
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
||
if (!assistant) throw new Error('assistant message missing');
|
||
const form = await context.formService.createForm(
|
||
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
|
||
parsedArgs,
|
||
);
|
||
assistant.metadata = {
|
||
...assistant.metadata,
|
||
a2uiForm: context.formService.serialize(form),
|
||
};
|
||
await context.messages.save(assistant);
|
||
|
||
await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成表单,等待用户填写' }, emit);
|
||
emit('ui.form', {
|
||
messageId,
|
||
form: context.formService.serialize(form),
|
||
});
|
||
return JSON.stringify({
|
||
status: 'success',
|
||
formId: form.id,
|
||
message: '表单已显示给用户,请提示用户填写并提交',
|
||
});
|
||
} catch {
|
||
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '表单参数无效', error: '表单参数无效' }, emit);
|
||
return JSON.stringify({ status: 'failed', error: '表单参数无效' });
|
||
}
|
||
}
|
||
|
||
export async function executeRenderReview(
|
||
context: AiChatServiceContext,
|
||
messageId: number,
|
||
call: ModelToolCall,
|
||
userId: number,
|
||
emit: AiSseEmitter,
|
||
): Promise<string> {
|
||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||
toolName: 'render_review',
|
||
skillKey: null,
|
||
argumentsData: null,
|
||
});
|
||
|
||
try {
|
||
const existingReview = await context.reviewService.findPendingByAssistantMessage(messageId);
|
||
if (existingReview) {
|
||
const denial = `本回合已生成导入预览《${existingReview.title}》,请直接提示用户审阅并确认,不要再次调用 render_review;如需多个分表,应全部合并到同一张预览卡。`;
|
||
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: denial, error: denial }, emit);
|
||
return JSON.stringify({ status: 'failed', error: denial });
|
||
}
|
||
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;
|
||
|
||
let review: AiReview;
|
||
if (Number.isInteger(attachmentId) && (attachmentId as number) > 0) {
|
||
const [attachment] = await context.attachmentService.requireReadyOwned(userId, [
|
||
attachmentId as number,
|
||
]);
|
||
if (
|
||
!attachment.mimeType.includes('spreadsheetml') &&
|
||
!attachment.mimeType.includes('excel') &&
|
||
!attachment.mimeType.includes('csv')
|
||
) {
|
||
throw new Error('附件不是 Excel 文件,无法生成导入预览');
|
||
}
|
||
if (!context.excelReader) throw new Error('Excel 解析器未配置');
|
||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
||
const sheets = await context.excelReader.loadSheets(buffer);
|
||
const sections = await context.reviewService.buildSectionsFromWorkbook(sheets, parsedArgs);
|
||
review = await context.reviewService.createReview(
|
||
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
|
||
{ title: parsedRecord.title, summary: parsedRecord.summary ?? null, sections },
|
||
);
|
||
} else {
|
||
review = await context.reviewService.createReview(
|
||
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
|
||
parsedArgs,
|
||
);
|
||
}
|
||
const expiredReviews = await context.reviewService.expirePreviousReviews(
|
||
userId,
|
||
assistant.conversationId,
|
||
review.id,
|
||
);
|
||
await Promise.all(
|
||
expiredReviews.map(async (expired) => {
|
||
const oldAssistant = await context.messages.findOne({
|
||
where: { id: expired.assistantMessageId, conversationId: assistant.conversationId },
|
||
});
|
||
const oldA2ui = oldAssistant?.metadata?.a2uiReview;
|
||
if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) {
|
||
oldAssistant.metadata = {
|
||
...oldAssistant.metadata,
|
||
a2uiReview: context.reviewService.serialize(expired),
|
||
};
|
||
await context.messages.save(oldAssistant);
|
||
}
|
||
emit('ui.review', {
|
||
messageId: expired.assistantMessageId,
|
||
review: context.reviewService.serialize(expired),
|
||
});
|
||
}),
|
||
);
|
||
assistant.metadata = {
|
||
...assistant.metadata,
|
||
a2uiReview: context.reviewService.serialize(review),
|
||
};
|
||
await context.messages.save(assistant);
|
||
|
||
await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成导入预览,等待用户确认' }, emit);
|
||
emit('ui.review', {
|
||
messageId,
|
||
review: context.reviewService.serialize(review),
|
||
});
|
||
return JSON.stringify({
|
||
status: 'success',
|
||
reviewId: review.id,
|
||
message: '导入预览已显示给用户,请提示用户审阅并确认',
|
||
});
|
||
} catch (reason) {
|
||
const errorMessage =
|
||
reason instanceof Error && reason.message ? reason.message.slice(0, 120) : '导入预览参数无效';
|
||
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: errorMessage, error: errorMessage }, emit);
|
||
return JSON.stringify({ status: 'failed', error: errorMessage });
|
||
}
|
||
}
|
||
|
||
export async function executeRenderChart(
|
||
context: AiChatServiceContext,
|
||
messageId: number,
|
||
call: ModelToolCall,
|
||
emit: AiSseEmitter,
|
||
): Promise<string> {
|
||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||
toolName: 'render_chart',
|
||
skillKey: null,
|
||
argumentsData: null,
|
||
});
|
||
|
||
try {
|
||
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
||
if (!assistant) throw new Error('assistant message missing');
|
||
const chart = context.chartService.createChart(parsedArgs);
|
||
const existingCharts = assistant.metadata?.a2uiChart;
|
||
const charts = Array.isArray(existingCharts)
|
||
? [...existingCharts]
|
||
: existingCharts
|
||
? [existingCharts]
|
||
: [];
|
||
charts.push(context.chartService.serialize(chart));
|
||
assistant.metadata = {
|
||
...assistant.metadata,
|
||
a2uiChart: charts,
|
||
};
|
||
await context.messages.save(assistant);
|
||
|
||
await finishToolRun(context, run, call, startedAt, { status: 'success', summary: '已生成图表' }, emit);
|
||
emit('ui.chart', {
|
||
messageId,
|
||
chart: context.chartService.serialize(chart),
|
||
});
|
||
return JSON.stringify({
|
||
status: 'success',
|
||
chartId: chart.id,
|
||
message: '图表已显示给用户',
|
||
});
|
||
} catch {
|
||
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '图表参数无效', error: '图表参数无效' }, emit);
|
||
return JSON.stringify({ status: 'failed', error: '图表参数无效' });
|
||
}
|
||
}
|