From 824c33a71c63413c276c26f8da44d433d79b99e3 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 12:13:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ai-chat):=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E8=B6=85=E5=A4=A7=E6=96=87=E4=BB=B6=E5=B9=B6=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20aislop=20100=20=E5=88=86=E9=97=A8=E6=A7=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiChatService 拆出抽象基类,职责不变 - submissions 按提交内容/评审确认/运行时/流程拆为 4 个模块并保持导出兼容 - 导入工具执行器抽取共享 runner,消除重复签名块 - 全仓 aislop 扫描 100/100,0 警告 --- .../src/ai-chat/ai-chat.review-confirm.ts | 128 +++++ .../src/ai-chat/ai-chat.service-base.ts | 372 +++++++++++++++ apps/server/src/ai-chat/ai-chat.service.ts | 360 ++------------ .../src/ai-chat/ai-chat.submissions.flow.ts | 142 ++++++ .../ai-chat/ai-chat.submissions.runtime.ts | 82 ++++ .../server/src/ai-chat/ai-chat.submissions.ts | 446 +----------------- .../src/ai-chat/ai-chat.submit-content.ts | 90 ++++ .../ai-chat/ai-chat.tool-actions.import.ts | 325 +++++++++++++ .../src/ai-chat/ai-chat.tool-actions.ts | 315 +------------ 9 files changed, 1202 insertions(+), 1058 deletions(-) create mode 100644 apps/server/src/ai-chat/ai-chat.review-confirm.ts create mode 100644 apps/server/src/ai-chat/ai-chat.service-base.ts create mode 100644 apps/server/src/ai-chat/ai-chat.submissions.flow.ts create mode 100644 apps/server/src/ai-chat/ai-chat.submissions.runtime.ts create mode 100644 apps/server/src/ai-chat/ai-chat.submit-content.ts create mode 100644 apps/server/src/ai-chat/ai-chat.tool-actions.import.ts diff --git a/apps/server/src/ai-chat/ai-chat.review-confirm.ts b/apps/server/src/ai-chat/ai-chat.review-confirm.ts new file mode 100644 index 0000000..c5f9630 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.review-confirm.ts @@ -0,0 +1,128 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { AiReview } from './entities/ai-review.entity'; +import type { AiReviewSectionType } from './entities/ai-review.entity'; +import type { AiChatServiceContext } from './ai-chat.types'; +import { reviewSectionType } from './ai-chat.types'; +import type { AuthenticatedUser } from '../authorization'; + +async function loadReviewForConfirm( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, +): Promise { + 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> { + 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 { + await context.opLog?.log({ + userId: user.id, + username: user.username, + module: '批量导入', + action, + detail, + targetType: 'ai_review', + status: 'success', + }); +} + +export function assertReviewImportPermissions( + context: AiChatServiceContext, + user: AuthenticatedUser, + review: AiReview, + sectionKey?: string, + sectionType?: AiReviewSectionType, +): void { + const sectionPermission: Record = { + students: 'student:create', + rooms: 'room:create', + transfers: 'occupancy:transfer', + checkins: 'occupancy:checkin', + }; + const ability = context.abilityFactory.createForUser(user); + const sections = context.reviewService.parseSections(review.sectionsJson); + const types = new Set(); + if (sectionType) { + types.add(sectionType); + } else if (sectionKey) { + const section = sections.find((item) => item.key === sectionKey); + if (!section) throw new NotFoundException(`分表不存在: ${sectionKey}`); + types.add(reviewSectionType(section)); + } else { + for (const section of sections) types.add(reviewSectionType(section)); + } + for (const type of types) { + context.authorization.assertPermission(ability, sectionPermission[type]); + } +} + +export async function confirmReviewStep( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, + sectionKey: string, +): Promise> { + 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 logImportOp( + context, + user, + '确认导入分表', + `「${review.title}」分表「${sectionKey}」:${message}`, + ); + return finalizeReview(context, updated); +} + +export async function confirmReviewGroup( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, + type: AiReviewSectionType, +): Promise> { + if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') { + throw new BadRequestException(`业务类型不支持: ${String(type)}`); + } + 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 + .parseSections(updated.sectionsJson) + .filter((section) => section.type === type) + .map((section) => section.title) + .join('、'); + await logImportOp( + context, + user, + '确认导入分组', + `「${review.title}」分组「${type}」:${sectionTitles}`, + ); + return finalizeReview(context, updated); +} diff --git a/apps/server/src/ai-chat/ai-chat.service-base.ts b/apps/server/src/ai-chat/ai-chat.service-base.ts new file mode 100644 index 0000000..20b633b --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.service-base.ts @@ -0,0 +1,372 @@ +import { DataSource, Repository } from 'typeorm'; +import { AiConfigService } from '../ai-config/ai-config.service'; +import { AgentToolExecutor } from '../agent-tools/agent-tool.executor'; +import { + AgentToolContextFactory, + type AgentSkillDescriptor, +} from '../agent-tools/agent-tool.types'; +import { AuthorizationService, CaslAbilityFactory, type AuthenticatedUser } from '../authorization'; +import { AiAttachmentService } from './ai-attachment.service'; +import { ImportsService } from '../imports/imports.service'; +import { AiChartService } from './ai-chart.service'; +import { AiExcelReaderService } from './ai-excel-reader.service'; +import { AiFormService } from './ai-form.service'; +import { AiReviewService } from './ai-review.service'; +import { AiModelStreamService } from './ai-model-stream.service'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { + AiConversation, + AiMessage, + AiReview, + AiToolRun, + type AiReviewSectionType, +} from './entities'; +import type { + AiChatServiceContext, + AiSseEmitter, + GenerationInput, + ModelContentPart, + ModelMessage, + ModelToolCall, + PublicConversation, +} from './ai-chat.types'; +import { + listConversations, + createConversation, + updateConversation, + deleteConversation, + deleteAllConversations, + getMessages, + deleteMessage, +} from './ai-chat.conversations'; +import { + streamMessage, + regenerateMessage, + editMessage, +} from './ai-chat.streaming'; +import { + resolveFormConversationId, + resolvePreflightConversationId, + resolveReviewConversationId, + resolveImportPreflight, + submitForm, + submitReview, + confirmReviewStep, + confirmReviewGroup, + assertReviewImportPermissions, + a2uiSubmitInfo, + buildFormSubmitModelContent, + markFormSubmittedOnMessage, + a2uiReviewSubmitInfo, + buildReviewSubmitModelContent, + markReviewSubmittedOnMessage, +} from './ai-chat.submissions'; +import { denyWriteTool, executeTool } from './ai-chat.tools'; +import { + executePreflightImport, + executeStartImportWizard, +} from './ai-chat.tool-actions'; +export abstract class AiChatServiceBase implements AiChatServiceContext { + readonly activeConversations = new Set(); + + abstract listSkills(user: AuthenticatedUser): AgentSkillDescriptor[]; + abstract serializeMessage(message: AiMessage): Record; + abstract redactText(value: string): string; + abstract summarize(value: unknown): string | null; + abstract safeStructured(value: unknown): unknown; + abstract parseToolArguments(value: string): unknown; + abstract safeToolName(name: string): string; + abstract throwIfAborted(signal: AbortSignal): void; + abstract errorCode(error: unknown): string; + abstract assertGeneratedLength(reasoning: string, content: string): void; + abstract buildContext( + conversationId: number, + focusUserMessageId: number, + focusContent: string | ModelContentPart[], + skillKey: string | null, + supportsVision: boolean, + ): Promise; + abstract buildUserContent( + text: string, + attachments: any[], + supportsVision: boolean, + ): Promise; + abstract truncateText(value: string, max: number): string; + abstract metadataSkillKey(metadata: Record | null): string | null; + abstract normalizeTitle(title?: string): string; + abstract titleFromMessage(message: string): string; + abstract assertSkillAvailable(user: AuthenticatedUser, skillKey?: string | null): void; + abstract requireOwnedConversation(userId: number, id: number): Promise; + abstract acquireConversation(conversationId: number): Promise; + abstract executeGeneration(input: GenerationInput): Promise; + + constructor( + readonly conversations: Repository, + readonly messages: Repository, + readonly toolRuns: Repository, + readonly dataSource: DataSource, + readonly configService: AiConfigService, + readonly toolExecutor: AgentToolExecutor, + readonly modelStream: AiModelStreamService, + readonly attachmentService: AiAttachmentService, + readonly formService: AiFormService, + readonly reviewService: AiReviewService, + readonly chartService: AiChartService, + readonly abilityFactory: CaslAbilityFactory, + readonly authorization: AuthorizationService, + readonly excelReader?: AiExcelReaderService, + readonly importsService?: ImportsService, + readonly opLog?: OperationLogsService, + ) {} + + a2uiSubmitInfo(metadata: Record | null) { + return a2uiSubmitInfo(metadata); + } + + a2uiReviewSubmitInfo(metadata: Record | null) { + return a2uiReviewSubmitInfo(metadata); + } + + buildFormSubmitModelContent(submit: { title: string; values: Record }): string { + return buildFormSubmitModelContent(submit); + } + + buildReviewSubmitModelContent(submit: { + reviewId: string; + reviewTitle: string; + resultMessage: string; + }): string { + return buildReviewSubmitModelContent(submit); + } + + markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise { + return markFormSubmittedOnMessage(this, assistantMessageId, conversationId); + } + + markReviewSubmittedOnMessage( + assistantMessageId: number, + conversationId: number, + review?: AiReview, + ): Promise { + return markReviewSubmittedOnMessage(this, assistantMessageId, conversationId, review); + } + + assertReviewImportPermissions( + user: AuthenticatedUser, + review: AiReview, + sectionKey?: string, + sectionType?: AiReviewSectionType, + ): void { + return assertReviewImportPermissions(this, user, review, sectionKey, sectionType); + } + + executeTool( + messageId: number, + call: ModelToolCall, + context: ReturnType, + allowedSkillKey: string | null, + allowWriteTools: boolean, + reviewSubmitted: boolean, + userId: number, + emit: AiSseEmitter, + ): Promise { + return executeTool( + this, + messageId, + call, + context, + allowedSkillKey, + allowWriteTools, + reviewSubmitted, + userId, + emit, + ); + } + + denyWriteTool( + messageId: number, + call: ModelToolCall, + emit: AiSseEmitter, + ): Promise { + return denyWriteTool(this, messageId, call, emit); + } + + executeStartImportWizard( + messageId: number, + call: ModelToolCall, + context: ReturnType, + emit: AiSseEmitter, + ): Promise { + return executeStartImportWizard(this, messageId, call, context, emit); + } + + executePreflightImport( + messageId: number, + call: ModelToolCall, + context: ReturnType, + emit: AiSseEmitter, + ): Promise { + return executePreflightImport(this, messageId, call, context, emit); + } + + listConversations(userId: number): Promise { + return listConversations(this, userId); + } + + createConversation( + user: AuthenticatedUser, + title?: string, + lockedSkillKey?: string | null, + ): Promise { + return createConversation(this, user, title, lockedSkillKey); + } + + updateConversation( + user: AuthenticatedUser, + id: number, + dto: { title?: string; lockedSkillKey?: string | null }, + ): Promise { + return updateConversation(this, user, id, dto); + } + + deleteConversation(userId: number, id: number): Promise { + return deleteConversation(this, userId, id); + } + + deleteAllConversations(userId: number): Promise { + return deleteAllConversations(this, userId); + } + + getMessages(userId: number, conversationId: number, page = 1, limit = 50) { + return getMessages(this, userId, conversationId, page, limit); + } + + deleteMessage( + userId: number, + conversationId: number, + messageId: number, + ): Promise<{ deletedIds: number[] }> { + return deleteMessage(this, userId, conversationId, messageId); + } + + streamMessage( + user: AuthenticatedUser, + conversationId: number, + dto: { + message: string; + attachmentIds?: number[]; + clientRequestId: string; + skillKey?: string | null; + reasoningEffort?: string | null; + }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return streamMessage(this, user, conversationId, dto, signal, emit, onReady); + } + + regenerateMessage( + user: AuthenticatedUser, + conversationId: number, + assistantMessageId: number, + clientRequestId: string, + reasoningEffort: string | null | undefined, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return regenerateMessage( + this, + user, + conversationId, + assistantMessageId, + clientRequestId, + reasoningEffort, + signal, + emit, + onReady, + ); + } + + editMessage( + user: AuthenticatedUser, + conversationId: number, + messageId: number, + dto: { content: string; clientRequestId: string; reasoningEffort?: string | null }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return editMessage(this, user, conversationId, messageId, dto, signal, emit, onReady); + } + + resolveFormConversationId(userId: number, formId: string): Promise { + return resolveFormConversationId(this, userId, formId); + } + + resolveReviewConversationId(userId: number, reviewId: string): Promise { + return resolveReviewConversationId(this, userId, reviewId); + } + + resolvePreflightConversationId(userId: number, messageId: number): Promise { + return resolvePreflightConversationId(this, userId, messageId); + } + + submitForm( + user: AuthenticatedUser, + formId: string, + dto: { + values: Record; + clientRequestId: string; + reasoningEffort?: string | null; + }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return submitForm(this, user, formId, dto, signal, emit, onReady); + } + + submitReview( + user: AuthenticatedUser, + reviewId: string, + dto: { clientRequestId: string; reasoningEffort?: string | null }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return submitReview(this, user, reviewId, dto, signal, emit, onReady); + } + + resolveImportPreflight( + user: AuthenticatedUser, + messageId: number, + dto: { + clientRequestId: string; + mapping?: Record; + settings?: Record; + }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady); + } + + confirmReviewStep( + user: AuthenticatedUser, + reviewId: string, + sectionKey: string, + ): Promise> { + return confirmReviewStep(this, user, reviewId, sectionKey); + } + + confirmReviewGroup( + user: AuthenticatedUser, + reviewId: string, + type: AiReviewSectionType, + ): Promise> { + return confirmReviewGroup(this, user, reviewId, type); + } +} diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index de180fa..3899efa 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -16,30 +16,13 @@ import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { - AiConversation, - AiMessage, - AiReview, - AiToolRun, - type AiReviewSectionType, -} from './entities'; +import { AiConversation, AiMessage, AiToolRun } from './entities'; import type { - AiChatServiceContext, - AiSseEmitter, GenerationInput, ModelContentPart, ModelMessage, - ModelToolCall, - PublicConversation, } from './ai-chat.types'; import { - listConversations, - createConversation, - updateConversation, - deleteConversation, - deleteAllConversations, - getMessages, - deleteMessage, requireOwnedConversation, acquireConversation, normalizeTitle, @@ -49,35 +32,7 @@ import { truncateText, serializeMessage, } from './ai-chat.conversations'; -import { - streamMessage, - regenerateMessage, - editMessage, - buildContext, - buildUserContent, -} from './ai-chat.streaming'; -import { - resolveFormConversationId, - resolvePreflightConversationId, - resolveReviewConversationId, - resolveImportPreflight, - submitForm, - submitReview, - confirmReviewStep, - confirmReviewGroup, - assertReviewImportPermissions, - a2uiSubmitInfo, - buildFormSubmitModelContent, - markFormSubmittedOnMessage, - a2uiReviewSubmitInfo, - buildReviewSubmitModelContent, - markReviewSubmittedOnMessage, -} from './ai-chat.submissions'; -import { denyWriteTool, executeTool } from './ai-chat.tools'; -import { - executePreflightImport, - executeStartImportWizard, -} from './ai-chat.tool-actions'; +import { buildContext, buildUserContent } from './ai-chat.streaming'; import { executeGeneration } from './ai-chat.generation'; import { assertGeneratedLength, @@ -88,32 +43,52 @@ import { safeToolName, throwIfAborted, } from './ai-chat.helpers'; +import { AiChatServiceBase } from './ai-chat.service-base'; @Injectable() -export class AiChatService implements AiChatServiceContext { - readonly activeConversations = new Set(); +export class AiChatService extends AiChatServiceBase { + private readonly redactingReplacer = makeRedactingReplacer((value) => this.redactText(value)); constructor( @InjectRepository(AiConversation) - readonly conversations: Repository, + conversations: Repository, @InjectRepository(AiMessage) - readonly messages: Repository, + messages: Repository, @InjectRepository(AiToolRun) - readonly toolRuns: Repository, - readonly dataSource: DataSource, - readonly configService: AiConfigService, - readonly toolExecutor: AgentToolExecutor, - readonly modelStream: AiModelStreamService, - readonly attachmentService: AiAttachmentService, - readonly formService: AiFormService, - readonly reviewService: AiReviewService, - readonly chartService: AiChartService, - readonly abilityFactory: CaslAbilityFactory, - readonly authorization: AuthorizationService, - readonly excelReader?: AiExcelReaderService, - readonly importsService?: ImportsService, - readonly opLog?: OperationLogsService, - ) {} + toolRuns: Repository, + dataSource: DataSource, + configService: AiConfigService, + toolExecutor: AgentToolExecutor, + modelStream: AiModelStreamService, + attachmentService: AiAttachmentService, + formService: AiFormService, + reviewService: AiReviewService, + chartService: AiChartService, + abilityFactory: CaslAbilityFactory, + authorization: AuthorizationService, + excelReader?: AiExcelReaderService, + importsService?: ImportsService, + opLog?: OperationLogsService, + ) { + super( + conversations, + messages, + toolRuns, + dataSource, + configService, + toolExecutor, + modelStream, + attachmentService, + formService, + reviewService, + chartService, + abilityFactory, + authorization, + excelReader, + importsService, + opLog, + ); + } listSkills(user: AuthenticatedUser): AgentSkillDescriptor[] { return this.toolExecutor.listSkills(AgentToolContextFactory.fromAuthenticatedUser(user)); @@ -167,49 +142,6 @@ export class AiChatService implements AiChatServiceContext { return assertGeneratedLength(reasoning, content); } - private readonly redactingReplacer = makeRedactingReplacer((value) => this.redactText(value)); - - a2uiSubmitInfo(metadata: Record | null) { - return a2uiSubmitInfo(metadata); - } - - a2uiReviewSubmitInfo(metadata: Record | null) { - return a2uiReviewSubmitInfo(metadata); - } - - buildFormSubmitModelContent(submit: { title: string; values: Record }): string { - return buildFormSubmitModelContent(submit); - } - - buildReviewSubmitModelContent(submit: { - reviewId: string; - reviewTitle: string; - resultMessage: string; - }): string { - return buildReviewSubmitModelContent(submit); - } - - markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise { - return markFormSubmittedOnMessage(this, assistantMessageId, conversationId); - } - - markReviewSubmittedOnMessage( - assistantMessageId: number, - conversationId: number, - review?: AiReview, - ): Promise { - return markReviewSubmittedOnMessage(this, assistantMessageId, conversationId, review); - } - - assertReviewImportPermissions( - user: AuthenticatedUser, - review: AiReview, - sectionKey?: string, - sectionType?: AiReviewSectionType, - ): void { - return assertReviewImportPermissions(this, user, review, sectionKey, sectionType); - } - buildContext( conversationId: number, focusUserMessageId: number, @@ -256,217 +188,7 @@ export class AiChatService implements AiChatServiceContext { return acquireConversation(this, conversationId); } - executeTool( - messageId: number, - call: ModelToolCall, - context: ReturnType, - allowedSkillKey: string | null, - allowWriteTools: boolean, - reviewSubmitted: boolean, - userId: number, - emit: AiSseEmitter, - ): Promise { - return executeTool( - this, - messageId, - call, - context, - allowedSkillKey, - allowWriteTools, - reviewSubmitted, - userId, - emit, - ); - } - - denyWriteTool( - messageId: number, - call: ModelToolCall, - emit: AiSseEmitter, - ): Promise { - return denyWriteTool(this, messageId, call, emit); - } - - executeStartImportWizard( - messageId: number, - call: ModelToolCall, - context: ReturnType, - emit: AiSseEmitter, - ): Promise { - return executeStartImportWizard(this, messageId, call, context, emit); - } - - executePreflightImport( - messageId: number, - call: ModelToolCall, - context: ReturnType, - emit: AiSseEmitter, - ): Promise { - return executePreflightImport(this, messageId, call, context, emit); - } - executeGeneration(input: GenerationInput): Promise { return executeGeneration(this, input); } - - listConversations(userId: number): Promise { - return listConversations(this, userId); - } - - createConversation( - user: AuthenticatedUser, - title?: string, - lockedSkillKey?: string | null, - ): Promise { - return createConversation(this, user, title, lockedSkillKey); - } - - updateConversation( - user: AuthenticatedUser, - id: number, - dto: { title?: string; lockedSkillKey?: string | null }, - ): Promise { - return updateConversation(this, user, id, dto); - } - - deleteConversation(userId: number, id: number): Promise { - return deleteConversation(this, userId, id); - } - - deleteAllConversations(userId: number): Promise { - return deleteAllConversations(this, userId); - } - - getMessages(userId: number, conversationId: number, page = 1, limit = 50) { - return getMessages(this, userId, conversationId, page, limit); - } - - deleteMessage( - userId: number, - conversationId: number, - messageId: number, - ): Promise<{ deletedIds: number[] }> { - return deleteMessage(this, userId, conversationId, messageId); - } - - streamMessage( - user: AuthenticatedUser, - conversationId: number, - dto: { - message: string; - attachmentIds?: number[]; - clientRequestId: string; - skillKey?: string | null; - reasoningEffort?: string | null; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return streamMessage(this, user, conversationId, dto, signal, emit, onReady); - } - - regenerateMessage( - user: AuthenticatedUser, - conversationId: number, - assistantMessageId: number, - clientRequestId: string, - reasoningEffort: string | null | undefined, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return regenerateMessage( - this, - user, - conversationId, - assistantMessageId, - clientRequestId, - reasoningEffort, - signal, - emit, - onReady, - ); - } - - editMessage( - user: AuthenticatedUser, - conversationId: number, - messageId: number, - dto: { content: string; clientRequestId: string; reasoningEffort?: string | null }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return editMessage(this, user, conversationId, messageId, dto, signal, emit, onReady); - } - - resolveFormConversationId(userId: number, formId: string): Promise { - return resolveFormConversationId(this, userId, formId); - } - - resolveReviewConversationId(userId: number, reviewId: string): Promise { - return resolveReviewConversationId(this, userId, reviewId); - } - - resolvePreflightConversationId(userId: number, messageId: number): Promise { - return resolvePreflightConversationId(this, userId, messageId); - } - - submitForm( - user: AuthenticatedUser, - formId: string, - dto: { - values: Record; - clientRequestId: string; - reasoningEffort?: string | null; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return submitForm(this, user, formId, dto, signal, emit, onReady); - } - - submitReview( - user: AuthenticatedUser, - reviewId: string, - dto: { clientRequestId: string; reasoningEffort?: string | null }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return submitReview(this, user, reviewId, dto, signal, emit, onReady); - } - - resolveImportPreflight( - user: AuthenticatedUser, - messageId: number, - dto: { - clientRequestId: string; - mapping?: Record; - settings?: Record; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady); - } - - confirmReviewStep( - user: AuthenticatedUser, - reviewId: string, - sectionKey: string, - ): Promise> { - return confirmReviewStep(this, user, reviewId, sectionKey); - } - - confirmReviewGroup( - user: AuthenticatedUser, - reviewId: string, - type: AiReviewSectionType, - ): Promise> { - return confirmReviewGroup(this, user, reviewId, type); - } } diff --git a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts new file mode 100644 index 0000000..1251cd3 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts @@ -0,0 +1,142 @@ +import type { AiChatServiceContext, AiSseEmitter } from './ai-chat.types'; +import { DEFAULT_TITLE } from './ai-chat.types'; +import type { AuthenticatedUser } from '../authorization'; +import { assertReviewImportPermissions } from './ai-chat.review-confirm'; +import { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime'; + +export async function submitForm( + context: AiChatServiceContext, + user: AuthenticatedUser, + formId: string, + dto: { values: Record; clientRequestId: string; reasoningEffort?: string | null }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, +): Promise { + const form = await context.formService.findOwnedPending(formId, user.id); + const conversation = await context.requireOwnedConversation(user.id, form.conversationId); + const values = context.formService.validateValues(form, dto.values); + const effectiveSkillKey = conversation.lockedSkillKey ?? null; + context.assertSkillAvailable(user, effectiveSkillKey); + + await context.acquireConversation(conversation.id); + try { + const summary = `已提交表单「${form.title}」`; + const saved = await context.dataSource.transaction(async (manager) => + persistExchange( + context, + manager, + conversation, + user.id, + summary, + dto.clientRequestId, + effectiveSkillKey, + { a2uiSubmit: { formId: form.id, formTitle: form.title, values } }, + undefined, + conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined, + ), + ); + + await context.formService.markSubmitted(form, values); + await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id); + + await runGenerationAndRelease(context, { + user, + conversation, + userMessage: saved.userMessage, + assistant: saved.assistantMessage, + clientRequestId: dto.clientRequestId, + effectiveSkillKey, + focusContent: summary, + reasoningEffort: dto.reasoningEffort ?? null, + signal, + emit, + onReady, + }, conversation.id); + } finally { + context.activeConversations.delete(conversation.id); + } +} + +export async function submitReview( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, + dto: { clientRequestId: string; reasoningEffort?: string | null }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, +): Promise { + const review = await context.reviewService.findOwnedPending(reviewId, user.id); + const conversation = await context.requireOwnedConversation(user.id, review.conversationId); + const effectiveSkillKey = conversation.lockedSkillKey ?? null; + context.assertSkillAvailable(user, effectiveSkillKey); + assertReviewImportPermissions(context, user, review); + + await context.acquireConversation(conversation.id); + try { + const { review: updatedReview, result } = await context.reviewService.submitAll( + review.id, + user.id, + ); + const summary = `已确认导入「${review.title}」:${result.message}`; + await context.opLog?.log({ + userId: user.id, + username: user.username, + module: '批量导入', + action: '确认导入全部', + detail: `「${review.title}」${result.message}`, + targetType: 'ai_review', + status: 'success', + }); + const saved = await context.dataSource.transaction(async (manager) => { + const exchange = await persistExchange( + context, + manager, + conversation, + user.id, + summary, + dto.clientRequestId, + effectiveSkillKey, + { + a2uiReviewSubmit: { + reviewId: review.id, + reviewTitle: review.title, + resultMessage: result.message, + }, + }, + undefined, + conversation.title === DEFAULT_TITLE ? review.title.slice(0, 30) : undefined, + ); + return { ...exchange, result }; + }); + + const serialized = context.reviewService.serialize(updatedReview); + onReady(); + emit('ui.review', { + messageId: updatedReview.assistantMessageId, + review: serialized, + }); + await context.markReviewSubmittedOnMessage( + updatedReview.assistantMessageId, + conversation.id, + updatedReview, + ); + + await runGenerationAndRelease(context, { + user, + conversation, + userMessage: saved.userMessage, + assistant: saved.assistantMessage, + clientRequestId: dto.clientRequestId, + effectiveSkillKey, + focusContent: saved.result.message, + reasoningEffort: dto.reasoningEffort ?? null, + signal, + emit, + onReady, + }, conversation.id); + } finally { + context.activeConversations.delete(conversation.id); + } +} diff --git a/apps/server/src/ai-chat/ai-chat.submissions.runtime.ts b/apps/server/src/ai-chat/ai-chat.submissions.runtime.ts new file mode 100644 index 0000000..23d8474 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.submissions.runtime.ts @@ -0,0 +1,82 @@ +import { EntityManager } from 'typeorm'; +import { AiConversation, AiMessage } from './entities'; +import type { + AiChatServiceContext, + AiSseEmitter, + ModelContentPart, +} from './ai-chat.types'; +import type { AuthenticatedUser } from '../authorization'; + +export async function persistExchange( + context: AiChatServiceContext, + manager: EntityManager, + conversation: AiConversation, + userId: number, + userContent: string, + clientRequestId: string | undefined, + skillKey: string | null, + metadata?: Record, + attachments?: any[], + titleUpdate?: string, +): Promise<{ userMessage: AiMessage; assistantMessage: AiMessage }> { + const userMessage = await manager.save( + AiMessage, + manager.create(AiMessage, { + conversationId: conversation.id, + role: 'user', + content: userContent, + reasoningContent: null, + status: 'completed', + errorCode: null, + replyToMessageId: null, + metadata: { clientRequestId, skillKey, ...metadata }, + attachments, + }), + ); + const assistantMessage = await manager.save( + AiMessage, + manager.create(AiMessage, { + conversationId: conversation.id, + role: 'assistant', + content: '', + reasoningContent: null, + status: 'pending', + errorCode: null, + replyToMessageId: userMessage.id, + metadata: { clientRequestId, skillKey }, + }), + ); + await manager.update( + AiConversation, + { id: conversation.id, userId }, + { + lastMessageAt: new Date(), + ...(titleUpdate ? { title: titleUpdate } : {}), + }, + ); + return { userMessage, assistantMessage }; +} + +export async function runGenerationAndRelease( + context: AiChatServiceContext, + input: { + user: AuthenticatedUser; + conversation: AiConversation; + userMessage: AiMessage; + assistant: AiMessage; + clientRequestId: string; + effectiveSkillKey: string | null; + focusContent: string | ModelContentPart[]; + reasoningEffort?: string | null; + signal: AbortSignal; + emit: AiSseEmitter; + onReady: () => void; + }, + conversationId: number, +): Promise { + try { + await context.executeGeneration(input); + } finally { + context.activeConversations.delete(conversationId); + } +} diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index 8479662..30397e8 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -1,17 +1,8 @@ -import { - BadRequestException, - ConflictException, - NotFoundException, -} from '@nestjs/common'; -import { EntityManager } from 'typeorm'; -import { AiReview } from './entities/ai-review.entity'; -import { AiConversation, AiMessage } from './entities'; -import type { AiReviewSectionType } from './entities/ai-review.entity'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import type { AiChatServiceContext, AiSseEmitter, } from './ai-chat.types'; -import { DEFAULT_TITLE, reviewSectionType } from './ai-chat.types'; import type { AuthenticatedUser } from '../authorization'; import type { ImportStageRequest, @@ -85,50 +76,6 @@ function isPreflightReport(value: object): value is PreflightReport { ); } -async function loadReviewForConfirm( - context: AiChatServiceContext, - user: AuthenticatedUser, - reviewId: string, -): Promise { - 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> { - 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 { - 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, @@ -230,379 +177,18 @@ export async function resolveImportPreflight( } } -export function assertReviewImportPermissions( - context: AiChatServiceContext, - user: AuthenticatedUser, - review: AiReview, - sectionKey?: string, - sectionType?: AiReviewSectionType, -): void { - const sectionPermission: Record = { - students: 'student:create', - rooms: 'room:create', - transfers: 'occupancy:transfer', - checkins: 'occupancy:checkin', - }; - const ability = context.abilityFactory.createForUser(user); - const sections = context.reviewService.parseSections(review.sectionsJson); - const types = new Set(); - if (sectionType) { - types.add(sectionType); - } else if (sectionKey) { - const section = sections.find((item) => item.key === sectionKey); - if (!section) throw new NotFoundException(`分表不存在: ${sectionKey}`); - types.add(reviewSectionType(section)); - } else { - for (const section of sections) types.add(reviewSectionType(section)); - } - for (const type of types) { - context.authorization.assertPermission(ability, sectionPermission[type]); - } -} - -export async function submitForm( - context: AiChatServiceContext, - user: AuthenticatedUser, - formId: string, - dto: { values: Record; clientRequestId: string; reasoningEffort?: string | null }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, -): Promise { - const form = await context.formService.findOwnedPending(formId, user.id); - const conversation = await context.requireOwnedConversation(user.id, form.conversationId); - const values = context.formService.validateValues(form, dto.values); - const effectiveSkillKey = conversation.lockedSkillKey ?? null; - context.assertSkillAvailable(user, effectiveSkillKey); - - await context.acquireConversation(conversation.id); - try { - const summary = `已提交表单「${form.title}」`; - const saved = await context.dataSource.transaction(async (manager) => - persistExchange( - context, - manager, - conversation, - user.id, - summary, - dto.clientRequestId, - effectiveSkillKey, - { a2uiSubmit: { formId: form.id, formTitle: form.title, values } }, - undefined, - conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined, - ), - ); - - await context.formService.markSubmitted(form, values); - await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id); - - await runGenerationAndRelease(context, { - user, - conversation, - userMessage: saved.userMessage, - assistant: saved.assistantMessage, - clientRequestId: dto.clientRequestId, - effectiveSkillKey, - focusContent: summary, - reasoningEffort: dto.reasoningEffort ?? null, - signal, - emit, - onReady, - }, conversation.id); - } finally { - context.activeConversations.delete(conversation.id); - } -} - -export async function submitReview( - context: AiChatServiceContext, - user: AuthenticatedUser, - reviewId: string, - dto: { clientRequestId: string; reasoningEffort?: string | null }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, -): Promise { - const review = await context.reviewService.findOwnedPending(reviewId, user.id); - const conversation = await context.requireOwnedConversation(user.id, review.conversationId); - const effectiveSkillKey = conversation.lockedSkillKey ?? null; - context.assertSkillAvailable(user, effectiveSkillKey); - assertReviewImportPermissions(context, user, review); - - await context.acquireConversation(conversation.id); - try { - const { review: updatedReview, result } = await context.reviewService.submitAll( - review.id, - user.id, - ); - const summary = `已确认导入「${review.title}」:${result.message}`; - await context.opLog?.log({ - userId: user.id, - username: user.username, - module: '批量导入', - action: '确认导入全部', - detail: `「${review.title}」${result.message}`, - targetType: 'ai_review', - status: 'success', - }); - const saved = await context.dataSource.transaction(async (manager) => { - const exchange = await persistExchange( - context, - manager, - conversation, - user.id, - summary, - dto.clientRequestId, - effectiveSkillKey, - { - a2uiReviewSubmit: { - reviewId: review.id, - reviewTitle: review.title, - resultMessage: result.message, - }, - }, - undefined, - conversation.title === DEFAULT_TITLE ? review.title.slice(0, 30) : undefined, - ); - return { ...exchange, result }; - }); - - const serialized = context.reviewService.serialize(updatedReview); - onReady(); - emit('ui.review', { - messageId: updatedReview.assistantMessageId, - review: serialized, - }); - await context.markReviewSubmittedOnMessage( - updatedReview.assistantMessageId, - conversation.id, - updatedReview, - ); - - await runGenerationAndRelease(context, { - user, - conversation, - userMessage: saved.userMessage, - assistant: saved.assistantMessage, - clientRequestId: dto.clientRequestId, - effectiveSkillKey, - focusContent: saved.result.message, - reasoningEffort: dto.reasoningEffort ?? null, - signal, - emit, - onReady, - }, conversation.id); - } finally { - context.activeConversations.delete(conversation.id); - } -} - -export async function confirmReviewStep( - context: AiChatServiceContext, - user: AuthenticatedUser, - reviewId: string, - sectionKey: string, -): Promise> { - 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 logImportOp( - context, - user, - '确认导入分表', - `「${review.title}」分表「${sectionKey}」:${message}`, - ); - return finalizeReview(context, updated); -} - -export async function confirmReviewGroup( - context: AiChatServiceContext, - user: AuthenticatedUser, - reviewId: string, - type: AiReviewSectionType, -): Promise> { - if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') { - throw new BadRequestException(`业务类型不支持: ${String(type)}`); - } - 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 - .parseSections(updated.sectionsJson) - .filter((section) => section.type === type) - .map((section) => section.title) - .join('、'); - await logImportOp( - context, - user, - '确认导入分组', - `「${review.title}」分组「${type}」:${sectionTitles}`, - ); - return finalizeReview(context, updated); -} - -export function a2uiSubmitInfo( - metadata: Record | null, -): { title: string; values: Record } | null { - const submit = metadata?.a2uiSubmit; - if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; - const record = submit as Record; - const title = typeof record.formTitle === 'string' ? record.formTitle : '表单'; - const values = - record.values && typeof record.values === 'object' && !Array.isArray(record.values) - ? (record.values as Record) - : {}; - return { title, values }; -} - -export function buildFormSubmitModelContent(submit: { - title: string; - values: Record; -}): string { - let json: string; - try { - json = JSON.stringify(submit.values); - } catch { - json = '[无法序列化]'; - } - return `【表单提交:${submit.title}】\n提交值(JSON):${json.slice(0, 32 * 1024)}\n用户已在表单中确认,你可以执行允许的写操作工具。`; -} - -export async function markFormSubmittedOnMessage( - context: AiChatServiceContext, - assistantMessageId: number, - conversationId: number, -): Promise { - const assistant = await context.messages.findOne({ - where: { id: assistantMessageId, conversationId }, - }); - const a2ui = assistant?.metadata?.a2uiForm; - if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) { - assistant.metadata = { - ...assistant.metadata, - a2uiForm: { ...(a2ui as Record), status: 'submitted' }, - }; - await context.messages.save(assistant); - } -} - -export function a2uiReviewSubmitInfo( - metadata: Record | null, -): { reviewId: string; reviewTitle: string; resultMessage: string } | null { - const submit = metadata?.a2uiReviewSubmit; - if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; - const record = submit as Record; - if (typeof record.reviewId !== 'string') return null; - return { - reviewId: record.reviewId, - reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入', - resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成', - }; -} - -export function buildReviewSubmitModelContent(submit: { - reviewId: string; - reviewTitle: string; - resultMessage: string; -}): string { - return `【批量导入已确认:${submit.reviewTitle}】\n${submit.resultMessage}\n数据已由系统入库,不要再次调用写入工具,直接向用户汇报导入结果即可。`; -} - -export async function markReviewSubmittedOnMessage( - context: AiChatServiceContext, - assistantMessageId: number, - conversationId: number, - review?: AiReview, -): Promise { - const assistant = await context.messages.findOne({ - where: { id: assistantMessageId, conversationId }, - }); - const a2ui = assistant?.metadata?.a2uiReview; - if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) { - assistant.metadata = { - ...assistant.metadata, - a2uiReview: review - ? context.reviewService.serialize(review) - : { ...(a2ui as Record), status: 'submitted' }, - }; - await context.messages.save(assistant); - } -} - -export async function persistExchange( - context: AiChatServiceContext, - manager: EntityManager, - conversation: AiConversation, - userId: number, - userContent: string, - clientRequestId: string | undefined, - skillKey: string | null, - metadata?: Record, - attachments?: any[], - titleUpdate?: string, -): Promise<{ userMessage: AiMessage; assistantMessage: AiMessage }> { - const userMessage = await manager.save( - AiMessage, - manager.create(AiMessage, { - conversationId: conversation.id, - role: 'user', - content: userContent, - reasoningContent: null, - status: 'completed', - errorCode: null, - replyToMessageId: null, - metadata: { clientRequestId, skillKey, ...metadata }, - attachments, - }), - ); - const assistantMessage = await manager.save( - AiMessage, - manager.create(AiMessage, { - conversationId: conversation.id, - role: 'assistant', - content: '', - reasoningContent: null, - status: 'pending', - errorCode: null, - replyToMessageId: userMessage.id, - metadata: { clientRequestId, skillKey }, - }), - ); - await manager.update( - AiConversation, - { id: conversation.id, userId }, - { - lastMessageAt: new Date(), - ...(titleUpdate ? { title: titleUpdate } : {}), - }, - ); - return { userMessage, assistantMessage }; -} - -export async function runGenerationAndRelease( - context: AiChatServiceContext, - input: { - user: AuthenticatedUser; - conversation: AiConversation; - userMessage: AiMessage; - assistant: AiMessage; - clientRequestId: string; - effectiveSkillKey: string | null; - focusContent: string | import('./ai-chat.types').ModelContentPart[]; - reasoningEffort?: string | null; - signal: AbortSignal; - emit: AiSseEmitter; - onReady: () => void; - }, - conversationId: number, -): Promise { - try { - await context.executeGeneration(input); - } finally { - context.activeConversations.delete(conversationId); - } -} +export { + assertReviewImportPermissions, + confirmReviewGroup, + confirmReviewStep, +} from './ai-chat.review-confirm'; +export { submitForm, submitReview } from './ai-chat.submissions.flow'; +export { + a2uiReviewSubmitInfo, + a2uiSubmitInfo, + buildFormSubmitModelContent, + buildReviewSubmitModelContent, + markFormSubmittedOnMessage, + markReviewSubmittedOnMessage, +} from './ai-chat.submit-content'; +export { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime'; diff --git a/apps/server/src/ai-chat/ai-chat.submit-content.ts b/apps/server/src/ai-chat/ai-chat.submit-content.ts new file mode 100644 index 0000000..b21668a --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.submit-content.ts @@ -0,0 +1,90 @@ +import { AiReview } from './entities/ai-review.entity'; +import type { AiChatServiceContext } from './ai-chat.types'; + +export function a2uiSubmitInfo( + metadata: Record | null, +): { title: string; values: Record } | null { + const submit = metadata?.a2uiSubmit; + if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; + const record = submit as Record; + const title = typeof record.formTitle === 'string' ? record.formTitle : '表单'; + const values = + record.values && typeof record.values === 'object' && !Array.isArray(record.values) + ? (record.values as Record) + : {}; + return { title, values }; +} + +export function buildFormSubmitModelContent(submit: { + title: string; + values: Record; +}): string { + let json: string; + try { + json = JSON.stringify(submit.values); + } catch { + json = '[无法序列化]'; + } + return `【表单提交:${submit.title}】\n提交值(JSON):${json.slice(0, 32 * 1024)}\n用户已在表单中确认,你可以执行允许的写操作工具。`; +} + +export async function markFormSubmittedOnMessage( + context: AiChatServiceContext, + assistantMessageId: number, + conversationId: number, +): Promise { + const assistant = await context.messages.findOne({ + where: { id: assistantMessageId, conversationId }, + }); + const a2ui = assistant?.metadata?.a2uiForm; + if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) { + assistant.metadata = { + ...assistant.metadata, + a2uiForm: { ...(a2ui as Record), status: 'submitted' }, + }; + await context.messages.save(assistant); + } +} + +export function a2uiReviewSubmitInfo( + metadata: Record | null, +): { reviewId: string; reviewTitle: string; resultMessage: string } | null { + const submit = metadata?.a2uiReviewSubmit; + if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; + const record = submit as Record; + if (typeof record.reviewId !== 'string') return null; + return { + reviewId: record.reviewId, + reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入', + resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成', + }; +} + +export function buildReviewSubmitModelContent(submit: { + reviewId: string; + reviewTitle: string; + resultMessage: string; +}): string { + return `【批量导入已确认:${submit.reviewTitle}】\n${submit.resultMessage}\n数据已由系统入库,不要再次调用写入工具,直接向用户汇报导入结果即可。`; +} + +export async function markReviewSubmittedOnMessage( + context: AiChatServiceContext, + assistantMessageId: number, + conversationId: number, + review?: AiReview, +): Promise { + const assistant = await context.messages.findOne({ + where: { id: assistantMessageId, conversationId }, + }); + const a2ui = assistant?.metadata?.a2uiReview; + if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) { + assistant.metadata = { + ...assistant.metadata, + a2uiReview: review + ? context.reviewService.serialize(review) + : { ...(a2ui as Record), status: 'submitted' }, + }; + await context.messages.save(assistant); + } +} diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts new file mode 100644 index 0000000..4f27e84 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -0,0 +1,325 @@ +import { + IMPORT_STEP_KEYS, + 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 { AiMessage } from './entities'; +import { finishToolRun, startToolRun } from './ai-chat.tools'; +import { + isExcelAttachment, + parseConfirmedMapping, + parseConfirmedSettings, +} from './ai-chat.import-confirm'; + +function parseAttachmentArgs( + parsedArgs: unknown, +): { parsedRecord: Record; attachmentId: number } { + const parsedRecord = + parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) + ? (parsedArgs as Record) + : {}; + 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, + }); +} + +interface ImportToolContext { + run: Awaited>['run']; + parsedArgs: Awaited>['parsedArgs']; + startedAt: Awaited>['startedAt']; + assistant: AiMessage; + agentContext: AgentToolContext; + context: AiChatServiceContext; + call: ModelToolCall; + emit: AiSseEmitter; + messageId: number; +} + +async function runImportTool( + context: AiChatServiceContext, + messageId: number, + call: ModelToolCall, + agentContext: AgentToolContext, + emit: AiSseEmitter, + toolName: string, + handler: (tool: ImportToolContext) => Promise, +): Promise { + const { run, parsedArgs, startedAt } = await beginImportToolRun( + context, + messageId, + call, + emit, + toolName, + ); + try { + const assistant = await context.messages.findOne({ where: { id: messageId } }); + if (!assistant) throw new Error('assistant message missing'); + return await handler({ run, parsedArgs, startedAt, assistant, agentContext, context, call, emit, messageId }); + } catch (error) { + const summary = error instanceof Error ? error.message.slice(0, 100) : `${toolName} 失败`; + await finishToolRun(context, run, call, startedAt, { + status: 'failed', + summary, + error: summary, + }, emit); + return JSON.stringify({ status: 'failed', error: run.resultSummary }); + } +} + +type ImportToolExecutor = ( + context: AiChatServiceContext, + messageId: number, + call: ModelToolCall, + agentContext: AgentToolContext, + emit: AiSseEmitter, +) => Promise; + +function makeImportToolExecutor( + toolName: 'preflight_import' | 'start_import_wizard', + handler: (tool: ImportToolContext) => Promise, +): ImportToolExecutor { + return (context, messageId, call, agentContext, emit) => + runImportTool(context, messageId, call, agentContext, emit, toolName, handler); +} + +export const executePreflightImport = makeImportToolExecutor( + 'preflight_import', + async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { + 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(ac.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, + }, headerRow); + const permittedSteps = permittedStepKeys({ + id: ac.userId, + permissions: [...ac.permissions], + isSuperAdmin: ac.isSuperAdmin, + }); + const preflightCard: PreflightReport = { + ...preflight, + attachmentId: attachment.id, + headerRow, + permittedSteps, + resolved: false, + runId: null, + }; + assistant.metadata = { + ...assistant.metadata, + a2uiImportPreflight: preflightCard, + }; + 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: preflightCard }); + return preflightModelPayload(preflight, permittedSteps); + }); + +export const executeStartImportWizard = makeImportToolExecutor( + 'start_import_wizard', + async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { + const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); + const [attachment] = await context.attachmentService.requireReadyOwned(ac.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 名`); + } + 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); + if (!context.importsService) throw new Error('导入向导服务未配置'); + const buffer = await context.attachmentService.readStoredBuffer(attachment); + const detail = await context.importsService.createRun( + { + id: ac.userId, + permissions: [...ac.permissions], + isSuperAdmin: ac.isSuperAdmin, + }, + 'ai', + { + originalName: attachment.originalName, + mimeType: attachment.mimeType, + size: attachment.size, + buffer, + }, + assistant.conversationId, + stages, + mapping, + 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), + resolved: true, + runId: detail.id, + }, + } + : {}), + 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); + if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) { + emit('ui.import_preflight', { + messageId, + preflight: { + ...(preflightMeta as Record), + resolved: true, + runId: detail.id, + }, + }); + } + 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 })), + permittedSteps: permittedStepKeys({ + id: ac.userId, + permissions: [...ac.permissions], + isSuperAdmin: ac.isSuperAdmin, + }), + message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库', + }); + }); + +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): { + 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, + })), + }; +} diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 6ac659d..f3a5dec 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -1,315 +1,6 @@ import { AiReview } from './entities/ai-review.entity'; -import { - IMPORT_STEP_KEYS, - 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 parseAttachmentArgs( - parsedArgs: unknown, -): { parsedRecord: Record; attachmentId: number } { - const parsedRecord = - parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) - ? (parsedArgs as Record) - : {}; - 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, - agentContext: AgentToolContext, - emit: AiSseEmitter, -): Promise { - 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, 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(agentContext.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, - }, 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: preflightCard, - }; - 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: preflightCard }); - return preflightModelPayload(preflight, permittedSteps); - } 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 { - 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, attachmentId } = parseAttachmentArgs(parsedArgs); - 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 名`); - } - 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); - 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); - const preflightMeta = assistant.metadata?.a2uiImportPreflight; - assistant.metadata = { - ...assistant.metadata, - ...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta) - ? { - a2uiImportPreflight: { - ...(preflightMeta as Record), - resolved: true, - runId: detail.id, - }, - } - : {}), - 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); - if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) { - emit('ui.import_preflight', { - messageId, - preflight: { - ...(preflightMeta as Record), - resolved: true, - runId: detail.id, - }, - }); - } - 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 })), - permittedSteps: permittedStepKeys({ - id: agentContext.userId, - permissions: [...agentContext.permissions], - isSuperAdmin: agentContext.isSuperAdmin, - }), - 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 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): { - 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, @@ -498,3 +189,9 @@ export async function executeRenderChart( return JSON.stringify({ status: 'failed', error: '图表参数无效' }); } } + +export { + compactImportWizard, + executePreflightImport, + executeStartImportWizard, +} from './ai-chat.tool-actions.import';