Files
gongxue-base/apps/server/src/ai-chat/ai-chat.tool-actions.ts

319 lines
12 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 { 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: '图表参数无效' });
}
}