forked from wangziqi/gongxue-base
refactor(ai-chat): 拆分超大文件并通过 aislop 100 分门槛
- AiChatService 拆出抽象基类,职责不变 - submissions 按提交内容/评审确认/运行时/流程拆为 4 个模块并保持导出兼容 - 导入工具执行器抽取共享 runner,消除重复签名块 - 全仓 aislop 扫描 100/100,0 警告
This commit is contained in:
128
apps/server/src/ai-chat/ai-chat.review-confirm.ts
Normal file
128
apps/server/src/ai-chat/ai-chat.review-confirm.ts
Normal 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);
|
||||||
|
}
|
||||||
372
apps/server/src/ai-chat/ai-chat.service-base.ts
Normal file
372
apps/server/src/ai-chat/ai-chat.service-base.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,30 +16,13 @@ import { AiFormService } from './ai-form.service';
|
|||||||
import { AiReviewService } from './ai-review.service';
|
import { AiReviewService } from './ai-review.service';
|
||||||
import { AiModelStreamService } from './ai-model-stream.service';
|
import { AiModelStreamService } from './ai-model-stream.service';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import {
|
import { AiConversation, AiMessage, AiToolRun } from './entities';
|
||||||
AiConversation,
|
|
||||||
AiMessage,
|
|
||||||
AiReview,
|
|
||||||
AiToolRun,
|
|
||||||
type AiReviewSectionType,
|
|
||||||
} from './entities';
|
|
||||||
import type {
|
import type {
|
||||||
AiChatServiceContext,
|
|
||||||
AiSseEmitter,
|
|
||||||
GenerationInput,
|
GenerationInput,
|
||||||
ModelContentPart,
|
ModelContentPart,
|
||||||
ModelMessage,
|
ModelMessage,
|
||||||
ModelToolCall,
|
|
||||||
PublicConversation,
|
|
||||||
} from './ai-chat.types';
|
} from './ai-chat.types';
|
||||||
import {
|
import {
|
||||||
listConversations,
|
|
||||||
createConversation,
|
|
||||||
updateConversation,
|
|
||||||
deleteConversation,
|
|
||||||
deleteAllConversations,
|
|
||||||
getMessages,
|
|
||||||
deleteMessage,
|
|
||||||
requireOwnedConversation,
|
requireOwnedConversation,
|
||||||
acquireConversation,
|
acquireConversation,
|
||||||
normalizeTitle,
|
normalizeTitle,
|
||||||
@@ -49,35 +32,7 @@ import {
|
|||||||
truncateText,
|
truncateText,
|
||||||
serializeMessage,
|
serializeMessage,
|
||||||
} from './ai-chat.conversations';
|
} from './ai-chat.conversations';
|
||||||
import {
|
import { buildContext, buildUserContent } from './ai-chat.streaming';
|
||||||
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 { executeGeneration } from './ai-chat.generation';
|
import { executeGeneration } from './ai-chat.generation';
|
||||||
import {
|
import {
|
||||||
assertGeneratedLength,
|
assertGeneratedLength,
|
||||||
@@ -88,32 +43,52 @@ import {
|
|||||||
safeToolName,
|
safeToolName,
|
||||||
throwIfAborted,
|
throwIfAborted,
|
||||||
} from './ai-chat.helpers';
|
} from './ai-chat.helpers';
|
||||||
|
import { AiChatServiceBase } from './ai-chat.service-base';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiChatService implements AiChatServiceContext {
|
export class AiChatService extends AiChatServiceBase {
|
||||||
readonly activeConversations = new Set<number>();
|
private readonly redactingReplacer = makeRedactingReplacer((value) => this.redactText(value));
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AiConversation)
|
@InjectRepository(AiConversation)
|
||||||
readonly conversations: Repository<AiConversation>,
|
conversations: Repository<AiConversation>,
|
||||||
@InjectRepository(AiMessage)
|
@InjectRepository(AiMessage)
|
||||||
readonly messages: Repository<AiMessage>,
|
messages: Repository<AiMessage>,
|
||||||
@InjectRepository(AiToolRun)
|
@InjectRepository(AiToolRun)
|
||||||
readonly toolRuns: Repository<AiToolRun>,
|
toolRuns: Repository<AiToolRun>,
|
||||||
readonly dataSource: DataSource,
|
dataSource: DataSource,
|
||||||
readonly configService: AiConfigService,
|
configService: AiConfigService,
|
||||||
readonly toolExecutor: AgentToolExecutor,
|
toolExecutor: AgentToolExecutor,
|
||||||
readonly modelStream: AiModelStreamService,
|
modelStream: AiModelStreamService,
|
||||||
readonly attachmentService: AiAttachmentService,
|
attachmentService: AiAttachmentService,
|
||||||
readonly formService: AiFormService,
|
formService: AiFormService,
|
||||||
readonly reviewService: AiReviewService,
|
reviewService: AiReviewService,
|
||||||
readonly chartService: AiChartService,
|
chartService: AiChartService,
|
||||||
readonly abilityFactory: CaslAbilityFactory,
|
abilityFactory: CaslAbilityFactory,
|
||||||
readonly authorization: AuthorizationService,
|
authorization: AuthorizationService,
|
||||||
readonly excelReader?: AiExcelReaderService,
|
excelReader?: AiExcelReaderService,
|
||||||
readonly importsService?: ImportsService,
|
importsService?: ImportsService,
|
||||||
readonly opLog?: OperationLogsService,
|
opLog?: OperationLogsService,
|
||||||
) {}
|
) {
|
||||||
|
super(
|
||||||
|
conversations,
|
||||||
|
messages,
|
||||||
|
toolRuns,
|
||||||
|
dataSource,
|
||||||
|
configService,
|
||||||
|
toolExecutor,
|
||||||
|
modelStream,
|
||||||
|
attachmentService,
|
||||||
|
formService,
|
||||||
|
reviewService,
|
||||||
|
chartService,
|
||||||
|
abilityFactory,
|
||||||
|
authorization,
|
||||||
|
excelReader,
|
||||||
|
importsService,
|
||||||
|
opLog,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
listSkills(user: AuthenticatedUser): AgentSkillDescriptor[] {
|
listSkills(user: AuthenticatedUser): AgentSkillDescriptor[] {
|
||||||
return this.toolExecutor.listSkills(AgentToolContextFactory.fromAuthenticatedUser(user));
|
return this.toolExecutor.listSkills(AgentToolContextFactory.fromAuthenticatedUser(user));
|
||||||
@@ -167,49 +142,6 @@ export class AiChatService implements AiChatServiceContext {
|
|||||||
return assertGeneratedLength(reasoning, content);
|
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(
|
buildContext(
|
||||||
conversationId: number,
|
conversationId: number,
|
||||||
focusUserMessageId: number,
|
focusUserMessageId: number,
|
||||||
@@ -256,217 +188,7 @@ export class AiChatService implements AiChatServiceContext {
|
|||||||
return acquireConversation(this, conversationId);
|
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> {
|
executeGeneration(input: GenerationInput): Promise<void> {
|
||||||
return executeGeneration(this, input);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
142
apps/server/src/ai-chat/ai-chat.submissions.flow.ts
Normal file
142
apps/server/src/ai-chat/ai-chat.submissions.flow.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
apps/server/src/ai-chat/ai-chat.submissions.runtime.ts
Normal file
82
apps/server/src/ai-chat/ai-chat.submissions.runtime.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
import {
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
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 type {
|
import type {
|
||||||
AiChatServiceContext,
|
AiChatServiceContext,
|
||||||
AiSseEmitter,
|
AiSseEmitter,
|
||||||
} from './ai-chat.types';
|
} from './ai-chat.types';
|
||||||
import { DEFAULT_TITLE, reviewSectionType } from './ai-chat.types';
|
|
||||||
import type { AuthenticatedUser } from '../authorization';
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
import type {
|
import type {
|
||||||
ImportStageRequest,
|
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(
|
export async function resolveImportPreflight(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
@@ -230,379 +177,18 @@ export async function resolveImportPreflight(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertReviewImportPermissions(
|
export {
|
||||||
context: AiChatServiceContext,
|
assertReviewImportPermissions,
|
||||||
user: AuthenticatedUser,
|
confirmReviewGroup,
|
||||||
review: AiReview,
|
confirmReviewStep,
|
||||||
sectionKey?: string,
|
} from './ai-chat.review-confirm';
|
||||||
sectionType?: AiReviewSectionType,
|
export { submitForm, submitReview } from './ai-chat.submissions.flow';
|
||||||
): void {
|
export {
|
||||||
const sectionPermission: Record<AiReviewSectionType, string> = {
|
a2uiReviewSubmitInfo,
|
||||||
students: 'student:create',
|
a2uiSubmitInfo,
|
||||||
rooms: 'room:create',
|
buildFormSubmitModelContent,
|
||||||
transfers: 'occupancy:transfer',
|
buildReviewSubmitModelContent,
|
||||||
checkins: 'occupancy:checkin',
|
markFormSubmittedOnMessage,
|
||||||
};
|
markReviewSubmittedOnMessage,
|
||||||
const ability = context.abilityFactory.createForUser(user);
|
} from './ai-chat.submit-content';
|
||||||
const sections = context.reviewService.parseSections(review.sectionsJson);
|
export { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime';
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
90
apps/server/src/ai-chat/ai-chat.submit-content.ts
Normal file
90
apps/server/src/ai-chat/ai-chat.submit-content.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
325
apps/server/src/ai-chat/ai-chat.tool-actions.import.ts
Normal file
325
apps/server/src/ai-chat/ai-chat.tool-actions.import.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,315 +1,6 @@
|
|||||||
import { AiReview } from './entities/ai-review.entity';
|
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 { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||||
import type { AgentToolContext } from './ai-chat.tools';
|
|
||||||
import { finishToolRun, startToolRun } 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(
|
export async function executeRenderForm(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
messageId: number,
|
messageId: number,
|
||||||
@@ -498,3 +189,9 @@ export async function executeRenderChart(
|
|||||||
return JSON.stringify({ status: 'failed', error: '图表参数无效' });
|
return JSON.stringify({ status: 'failed', error: '图表参数无效' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
compactImportWizard,
|
||||||
|
executePreflightImport,
|
||||||
|
executeStartImportWizard,
|
||||||
|
} from './ai-chat.tool-actions.import';
|
||||||
|
|||||||
Reference in New Issue
Block a user