- 新增代码内业务上下文元数据层(实体字典 + 三大闭环工作流) - 新增 get_business_context / get_entity_schema / get_pending_tasks 运行时工具 - SYSTEM_PROMPT 与技能目录改为先查业务流程/待办再执行 - A2UI 增加 ai_a2ui_submissions 幂等表、表单过期、ui.artifact 事件 - 提交回灌携带 submissionId / fieldErrors / 下一步建议 - 前端 uiArtifacts 归一化与过期表单禁用
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { AiReview } from './entities/ai-review.entity';
|
||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||
import { buildA2uiArtifact } from './ai-a2ui.artifact';
|
||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||
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,
|
||
);
|
||
const expiredForms = await context.formService.expirePreviousForms(
|
||
userId,
|
||
assistant.conversationId,
|
||
form.id,
|
||
);
|
||
await Promise.all(
|
||
expiredForms.map(async (expired) => {
|
||
const oldAssistant = await context.messages.findOne({
|
||
where: { id: expired.assistantMessageId, conversationId: assistant.conversationId },
|
||
});
|
||
const oldA2ui = oldAssistant?.metadata?.a2uiForm;
|
||
if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) {
|
||
oldAssistant.metadata = {
|
||
...oldAssistant.metadata,
|
||
a2uiForm: context.formService.serialize(expired),
|
||
};
|
||
await context.messages.save(oldAssistant);
|
||
}
|
||
const expiredPayload = context.formService.serialize(expired);
|
||
emit('ui.form', { messageId: expired.assistantMessageId, form: expiredPayload });
|
||
emit('ui.artifact', {
|
||
messageId: expired.assistantMessageId,
|
||
artifact: buildA2uiArtifact({
|
||
type: 'form',
|
||
id: expired.id,
|
||
status: 'expired',
|
||
messageId: expired.assistantMessageId,
|
||
conversationId: assistant.conversationId,
|
||
payload: expiredPayload,
|
||
}),
|
||
});
|
||
}),
|
||
);
|
||
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),
|
||
});
|
||
emit('ui.artifact', {
|
||
messageId,
|
||
artifact: buildA2uiArtifact({
|
||
type: 'form',
|
||
id: form.id,
|
||
status: form.status === 'submitted' ? 'submitted' : 'pending',
|
||
messageId,
|
||
conversationId: assistant.conversationId,
|
||
payload: 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),
|
||
});
|
||
emit('ui.artifact', {
|
||
messageId: expired.assistantMessageId,
|
||
artifact: buildA2uiArtifact({
|
||
type: 'review',
|
||
id: expired.id,
|
||
status: expired.status === 'submitted' ? 'submitted' : 'expired',
|
||
messageId: expired.assistantMessageId,
|
||
conversationId: assistant.conversationId,
|
||
payload: 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),
|
||
});
|
||
emit('ui.artifact', {
|
||
messageId,
|
||
artifact: buildA2uiArtifact({
|
||
type: 'review',
|
||
id: review.id,
|
||
status: review.status === 'submitted' ? 'submitted' : 'pending',
|
||
messageId,
|
||
conversationId: assistant.conversationId,
|
||
payload: 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),
|
||
});
|
||
emit('ui.artifact', {
|
||
messageId,
|
||
artifact: buildA2uiArtifact({
|
||
type: 'chart',
|
||
id: chart.id,
|
||
status: 'pending',
|
||
messageId,
|
||
conversationId: assistant.conversationId,
|
||
payload: 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: '图表参数无效' });
|
||
}
|
||
}
|
||
|
||
export {
|
||
compactImportWizard,
|
||
executePreflightImport,
|
||
executeStartImportWizard,
|
||
} from './ai-chat.tool-actions.import';
|