refactor(ai-chat): 拆分超大文件并通过 aislop 100 分门槛

- AiChatService 拆出抽象基类,职责不变
- submissions 按提交内容/评审确认/运行时/流程拆为 4 个模块并保持导出兼容
- 导入工具执行器抽取共享 runner,消除重复签名块
- 全仓 aislop 扫描 100/100,0 警告
This commit is contained in:
2026-08-06 12:13:37 +08:00
parent 7f09d5271e
commit 824c33a71c
9 changed files with 1202 additions and 1058 deletions

View File

@@ -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<AiReview> {
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<Record<string, unknown>> {
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<void> {
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<AiReviewSectionType, string> = {
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<AiReviewSectionType>();
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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);
}

View File

@@ -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<number>();
abstract listSkills(user: AuthenticatedUser): AgentSkillDescriptor[];
abstract serializeMessage(message: AiMessage): Record<string, unknown>;
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<ModelMessage[]>;
abstract buildUserContent(
text: string,
attachments: any[],
supportsVision: boolean,
): Promise<string | ModelContentPart[]>;
abstract truncateText(value: string, max: number): string;
abstract metadataSkillKey(metadata: Record<string, unknown> | 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<AiConversation>;
abstract acquireConversation(conversationId: number): Promise<void>;
abstract executeGeneration(input: GenerationInput): Promise<void>;
constructor(
readonly conversations: Repository<AiConversation>,
readonly messages: Repository<AiMessage>,
readonly toolRuns: Repository<AiToolRun>,
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<string, unknown> | null) {
return a2uiSubmitInfo(metadata);
}
a2uiReviewSubmitInfo(metadata: Record<string, unknown> | null) {
return a2uiReviewSubmitInfo(metadata);
}
buildFormSubmitModelContent(submit: { title: string; values: Record<string, unknown> }): string {
return buildFormSubmitModelContent(submit);
}
buildReviewSubmitModelContent(submit: {
reviewId: string;
reviewTitle: string;
resultMessage: string;
}): string {
return buildReviewSubmitModelContent(submit);
}
markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise<void> {
return markFormSubmittedOnMessage(this, assistantMessageId, conversationId);
}
markReviewSubmittedOnMessage(
assistantMessageId: number,
conversationId: number,
review?: AiReview,
): Promise<void> {
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<typeof AgentToolContextFactory.fromAuthenticatedUser>,
allowedSkillKey: string | null,
allowWriteTools: boolean,
reviewSubmitted: boolean,
userId: number,
emit: AiSseEmitter,
): Promise<string> {
return executeTool(
this,
messageId,
call,
context,
allowedSkillKey,
allowWriteTools,
reviewSubmitted,
userId,
emit,
);
}
denyWriteTool(
messageId: number,
call: ModelToolCall,
emit: AiSseEmitter,
): Promise<string> {
return denyWriteTool(this, messageId, call, emit);
}
executeStartImportWizard(
messageId: number,
call: ModelToolCall,
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
emit: AiSseEmitter,
): Promise<string> {
return executeStartImportWizard(this, messageId, call, context, emit);
}
executePreflightImport(
messageId: number,
call: ModelToolCall,
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
emit: AiSseEmitter,
): Promise<string> {
return executePreflightImport(this, messageId, call, context, emit);
}
listConversations(userId: number): Promise<PublicConversation[]> {
return listConversations(this, userId);
}
createConversation(
user: AuthenticatedUser,
title?: string,
lockedSkillKey?: string | null,
): Promise<PublicConversation> {
return createConversation(this, user, title, lockedSkillKey);
}
updateConversation(
user: AuthenticatedUser,
id: number,
dto: { title?: string; lockedSkillKey?: string | null },
): Promise<PublicConversation> {
return updateConversation(this, user, id, dto);
}
deleteConversation(userId: number, id: number): Promise<void> {
return deleteConversation(this, userId, id);
}
deleteAllConversations(userId: number): Promise<number> {
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<void> {
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<void> {
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<void> {
return editMessage(this, user, conversationId, messageId, dto, signal, emit, onReady);
}
resolveFormConversationId(userId: number, formId: string): Promise<number> {
return resolveFormConversationId(this, userId, formId);
}
resolveReviewConversationId(userId: number, reviewId: string): Promise<number> {
return resolveReviewConversationId(this, userId, reviewId);
}
resolvePreflightConversationId(userId: number, messageId: number): Promise<number> {
return resolvePreflightConversationId(this, userId, messageId);
}
submitForm(
user: AuthenticatedUser,
formId: string,
dto: {
values: Record<string, unknown>;
clientRequestId: string;
reasoningEffort?: string | null;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
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<void> {
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
}
resolveImportPreflight(
user: AuthenticatedUser,
messageId: number,
dto: {
clientRequestId: string;
mapping?: Record<string, unknown>;
settings?: Record<string, unknown>;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady);
}
confirmReviewStep(
user: AuthenticatedUser,
reviewId: string,
sectionKey: string,
): Promise<Record<string, unknown>> {
return confirmReviewStep(this, user, reviewId, sectionKey);
}
confirmReviewGroup(
user: AuthenticatedUser,
reviewId: string,
type: AiReviewSectionType,
): Promise<Record<string, unknown>> {
return confirmReviewGroup(this, user, reviewId, type);
}
}

View File

@@ -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<number>();
export class AiChatService extends AiChatServiceBase {
private readonly redactingReplacer = makeRedactingReplacer((value) => this.redactText(value));
constructor(
@InjectRepository(AiConversation)
readonly conversations: Repository<AiConversation>,
conversations: Repository<AiConversation>,
@InjectRepository(AiMessage)
readonly messages: Repository<AiMessage>,
messages: Repository<AiMessage>,
@InjectRepository(AiToolRun)
readonly toolRuns: Repository<AiToolRun>,
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<AiToolRun>,
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<string, unknown> | null) {
return a2uiSubmitInfo(metadata);
}
a2uiReviewSubmitInfo(metadata: Record<string, unknown> | null) {
return a2uiReviewSubmitInfo(metadata);
}
buildFormSubmitModelContent(submit: { title: string; values: Record<string, unknown> }): string {
return buildFormSubmitModelContent(submit);
}
buildReviewSubmitModelContent(submit: {
reviewId: string;
reviewTitle: string;
resultMessage: string;
}): string {
return buildReviewSubmitModelContent(submit);
}
markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise<void> {
return markFormSubmittedOnMessage(this, assistantMessageId, conversationId);
}
markReviewSubmittedOnMessage(
assistantMessageId: number,
conversationId: number,
review?: AiReview,
): Promise<void> {
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<typeof AgentToolContextFactory.fromAuthenticatedUser>,
allowedSkillKey: string | null,
allowWriteTools: boolean,
reviewSubmitted: boolean,
userId: number,
emit: AiSseEmitter,
): Promise<string> {
return executeTool(
this,
messageId,
call,
context,
allowedSkillKey,
allowWriteTools,
reviewSubmitted,
userId,
emit,
);
}
denyWriteTool(
messageId: number,
call: ModelToolCall,
emit: AiSseEmitter,
): Promise<string> {
return denyWriteTool(this, messageId, call, emit);
}
executeStartImportWizard(
messageId: number,
call: ModelToolCall,
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
emit: AiSseEmitter,
): Promise<string> {
return executeStartImportWizard(this, messageId, call, context, emit);
}
executePreflightImport(
messageId: number,
call: ModelToolCall,
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
emit: AiSseEmitter,
): Promise<string> {
return executePreflightImport(this, messageId, call, context, emit);
}
executeGeneration(input: GenerationInput): Promise<void> {
return executeGeneration(this, input);
}
listConversations(userId: number): Promise<PublicConversation[]> {
return listConversations(this, userId);
}
createConversation(
user: AuthenticatedUser,
title?: string,
lockedSkillKey?: string | null,
): Promise<PublicConversation> {
return createConversation(this, user, title, lockedSkillKey);
}
updateConversation(
user: AuthenticatedUser,
id: number,
dto: { title?: string; lockedSkillKey?: string | null },
): Promise<PublicConversation> {
return updateConversation(this, user, id, dto);
}
deleteConversation(userId: number, id: number): Promise<void> {
return deleteConversation(this, userId, id);
}
deleteAllConversations(userId: number): Promise<number> {
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<void> {
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<void> {
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<void> {
return editMessage(this, user, conversationId, messageId, dto, signal, emit, onReady);
}
resolveFormConversationId(userId: number, formId: string): Promise<number> {
return resolveFormConversationId(this, userId, formId);
}
resolveReviewConversationId(userId: number, reviewId: string): Promise<number> {
return resolveReviewConversationId(this, userId, reviewId);
}
resolvePreflightConversationId(userId: number, messageId: number): Promise<number> {
return resolvePreflightConversationId(this, userId, messageId);
}
submitForm(
user: AuthenticatedUser,
formId: string,
dto: {
values: Record<string, unknown>;
clientRequestId: string;
reasoningEffort?: string | null;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
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<void> {
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
}
resolveImportPreflight(
user: AuthenticatedUser,
messageId: number,
dto: {
clientRequestId: string;
mapping?: Record<string, unknown>;
settings?: Record<string, unknown>;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady);
}
confirmReviewStep(
user: AuthenticatedUser,
reviewId: string,
sectionKey: string,
): Promise<Record<string, unknown>> {
return confirmReviewStep(this, user, reviewId, sectionKey);
}
confirmReviewGroup(
user: AuthenticatedUser,
reviewId: string,
type: AiReviewSectionType,
): Promise<Record<string, unknown>> {
return confirmReviewGroup(this, user, reviewId, type);
}
}

View File

@@ -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<string, unknown>; clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
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<void> {
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);
}
}

View File

@@ -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<string, unknown>,
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<void> {
try {
await context.executeGeneration(input);
} finally {
context.activeConversations.delete(conversationId);
}
}

View File

@@ -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<AiReview> {
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<Record<string, unknown>> {
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<void> {
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<AiReviewSectionType, string> = {
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<AiReviewSectionType>();
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<string, unknown>; clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
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<void> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> | null,
): { title: string; values: Record<string, unknown> } | null {
const submit = metadata?.a2uiSubmit;
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
const record = submit as Record<string, unknown>;
const title = typeof record.formTitle === 'string' ? record.formTitle : '表单';
const values =
record.values && typeof record.values === 'object' && !Array.isArray(record.values)
? (record.values as Record<string, unknown>)
: {};
return { title, values };
}
export function buildFormSubmitModelContent(submit: {
title: string;
values: Record<string, unknown>;
}): 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<void> {
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<string, unknown>), status: 'submitted' },
};
await context.messages.save(assistant);
}
}
export function a2uiReviewSubmitInfo(
metadata: Record<string, unknown> | 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<string, unknown>;
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<void> {
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<string, unknown>), 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<string, unknown>,
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<void> {
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';

View File

@@ -0,0 +1,90 @@
import { AiReview } from './entities/ai-review.entity';
import type { AiChatServiceContext } from './ai-chat.types';
export function a2uiSubmitInfo(
metadata: Record<string, unknown> | null,
): { title: string; values: Record<string, unknown> } | null {
const submit = metadata?.a2uiSubmit;
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
const record = submit as Record<string, unknown>;
const title = typeof record.formTitle === 'string' ? record.formTitle : '表单';
const values =
record.values && typeof record.values === 'object' && !Array.isArray(record.values)
? (record.values as Record<string, unknown>)
: {};
return { title, values };
}
export function buildFormSubmitModelContent(submit: {
title: string;
values: Record<string, unknown>;
}): 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<void> {
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<string, unknown>), status: 'submitted' },
};
await context.messages.save(assistant);
}
}
export function a2uiReviewSubmitInfo(
metadata: Record<string, unknown> | 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<string, unknown>;
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<void> {
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<string, unknown>), status: 'submitted' },
};
await context.messages.save(assistant);
}
}

View File

@@ -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<string, unknown>; attachmentId: number } {
const parsedRecord =
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
? (parsedArgs as Record<string, unknown>)
: {};
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<ReturnType<typeof beginImportToolRun>>['run'];
parsedArgs: Awaited<ReturnType<typeof beginImportToolRun>>['parsedArgs'];
startedAt: Awaited<ReturnType<typeof beginImportToolRun>>['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<string>,
): Promise<string> {
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<string>;
function makeImportToolExecutor(
toolName: 'preflight_import' | 'start_import_wizard',
handler: (tool: ImportToolContext) => Promise<string>,
): 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<string, unknown>),
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<string, unknown>),
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,
})),
};
}

View File

@@ -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<string, unknown>; attachmentId: number } {
const parsedRecord =
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
? (parsedArgs as Record<string, unknown>)
: {};
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<string> {
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<string> {
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<string, unknown>),
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<string, unknown>),
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';