feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具

This commit is contained in:
2026-08-05 17:11:00 +08:00
parent 644c35ce53
commit 0e6e3e2d96
64 changed files with 8395 additions and 6434 deletions

View File

@@ -0,0 +1,318 @@
import { AiReview } from './entities/ai-review.entity';
import { IMPORT_STEP_KEYS, type ImportStageRequest } 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';
export { executeOfficeAnalyze, buildOfficeCliArgs } from './ai-chat.tool-office';
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,
]);
const isExcel =
attachment.mimeType.includes('spreadsheetml') ||
attachment.mimeType.includes('excel') ||
attachment.mimeType.includes('csv') ||
/\.(xlsx|csv)$/i.test(attachment.originalName);
if (!isExcel) 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 (!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,
);
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 });
}
}
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: '图表参数无效' });
}
}