feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
@@ -66,14 +66,18 @@ function createService(
|
||||
|
||||
describe('AiChatService', () => {
|
||||
it('按 userId 查询会话,无法借 id 访问其他用户会话', async () => {
|
||||
const { service, conversations } = createService({ findOne: jest.fn().mockResolvedValue(null) });
|
||||
const { service, conversations } = createService({
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
await expect(service.getMessages(7, 99)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(conversations.findOne).toHaveBeenCalledWith({ where: { id: 99, userId: 7 } });
|
||||
});
|
||||
|
||||
it('生成中的会话禁止删除', async () => {
|
||||
const entity = { id: 2, userId: 7 };
|
||||
const { service, conversations } = createService({ findOne: jest.fn().mockResolvedValue(entity) });
|
||||
const { service, conversations } = createService({
|
||||
findOne: jest.fn().mockResolvedValue(entity),
|
||||
});
|
||||
(service as unknown as { activeConversations: Set<number> }).activeConversations.add(2);
|
||||
await expect(service.deleteConversation(7, 2)).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(conversations.remove).not.toHaveBeenCalled();
|
||||
@@ -136,14 +140,16 @@ describe('AiChatService', () => {
|
||||
it('并发获取同一会话时只允许一个请求进入生成流程', async () => {
|
||||
let resolveExists!: (value: boolean) => void;
|
||||
const exists = jest.fn(
|
||||
() => new Promise<boolean>((resolve) => {
|
||||
resolveExists = resolve;
|
||||
}),
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveExists = resolve;
|
||||
}),
|
||||
);
|
||||
const { service } = createService();
|
||||
(service as unknown as { messages: { exists: typeof exists } }).messages.exists = exists;
|
||||
const acquire = (service as unknown as { acquireConversation(id: number): Promise<void> })
|
||||
.acquireConversation.bind(service);
|
||||
const acquire = (
|
||||
service as unknown as { acquireConversation(id: number): Promise<void> }
|
||||
).acquireConversation.bind(service);
|
||||
|
||||
const first = acquire(5);
|
||||
await expect(acquire(5)).rejects.toBeInstanceOf(ConflictException);
|
||||
@@ -153,7 +159,9 @@ describe('AiChatService', () => {
|
||||
|
||||
it('工具摘要脱敏并限制长度', () => {
|
||||
const { service } = createService();
|
||||
const summarize = (service as unknown as { summarize(value: unknown): string }).summarize.bind(service);
|
||||
const summarize = (service as unknown as { summarize(value: unknown): string }).summarize.bind(
|
||||
service,
|
||||
);
|
||||
const summary = summarize({
|
||||
phone: '13800138000',
|
||||
idCard: '11010519491231002X',
|
||||
@@ -170,17 +178,21 @@ describe('AiChatService', () => {
|
||||
it('超大附件文本在进入模型前被截断并提示', async () => {
|
||||
const { service } = createService();
|
||||
(service as unknown as { attachmentService: { toModelParts: jest.Mock } }).attachmentService = {
|
||||
toModelParts: jest.fn().mockResolvedValue([
|
||||
{ attachment: { id: 1, originalName: 'big.xlsx' }, text: 'x'.repeat(120000) },
|
||||
]),
|
||||
toModelParts: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ attachment: { id: 1, originalName: 'big.xlsx' }, text: 'x'.repeat(120000) },
|
||||
]),
|
||||
};
|
||||
const build = (service as unknown as {
|
||||
buildUserContent(
|
||||
text: string,
|
||||
attachments: unknown[],
|
||||
supportsVision: boolean,
|
||||
): Promise<string | unknown[]>;
|
||||
}).buildUserContent.bind(service);
|
||||
const build = (
|
||||
service as unknown as {
|
||||
buildUserContent(
|
||||
text: string,
|
||||
attachments: unknown[],
|
||||
supportsVision: boolean,
|
||||
): Promise<string | unknown[]>;
|
||||
}
|
||||
).buildUserContent.bind(service);
|
||||
const result = await build('请看这个文件', [{ id: 1 }], false);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result as string).toContain('内容过长');
|
||||
@@ -205,13 +217,15 @@ describe('AiChatService', () => {
|
||||
},
|
||||
]),
|
||||
};
|
||||
const build = (service as unknown as {
|
||||
buildUserContent(
|
||||
text: string,
|
||||
attachments: unknown[],
|
||||
supportsVision: boolean,
|
||||
): Promise<string | unknown[]>;
|
||||
}).buildUserContent.bind(service);
|
||||
const build = (
|
||||
service as unknown as {
|
||||
buildUserContent(
|
||||
text: string,
|
||||
attachments: unknown[],
|
||||
supportsVision: boolean,
|
||||
): Promise<string | unknown[]>;
|
||||
}
|
||||
).buildUserContent.bind(service);
|
||||
const result = await build('请看这个文件', [{ id: 1 }], false);
|
||||
expect(result as string).toContain('# 名单(共 100 行)');
|
||||
expect(result as string).toContain('office_analyze');
|
||||
@@ -220,113 +234,116 @@ describe('AiChatService', () => {
|
||||
it.each([
|
||||
{ abort: false, expectedStatus: 'failed', expectedCode: 'UPSTREAM_ERROR' },
|
||||
{ abort: true, expectedStatus: 'cancelled', expectedCode: 'CLIENT_ABORTED' },
|
||||
])('流中断后保存已生成内容和 $expectedStatus 状态', async ({ abort, expectedStatus, expectedCode }) => {
|
||||
const conversation = {
|
||||
id: 3,
|
||||
userId: 7,
|
||||
title: '测试',
|
||||
lockedSkillKey: null,
|
||||
lastMessageAt: null,
|
||||
};
|
||||
const assistant = {
|
||||
id: 12,
|
||||
conversationId: 3,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: null,
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
};
|
||||
const messageSave = jest.fn(async (value) => value);
|
||||
const messages = {
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: messageSave,
|
||||
};
|
||||
const manager = {
|
||||
create: jest.fn((_entity, value) => value),
|
||||
save: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '查询' })
|
||||
.mockResolvedValueOnce(assistant),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
const modelStream = {
|
||||
stream: async function* () {
|
||||
yield { type: 'content' as const, delta: '部分回答' };
|
||||
if (abort) {
|
||||
abortController.abort(new Error('client disconnected'));
|
||||
yield { type: 'complete' as const, toolCalls: [] };
|
||||
return;
|
||||
}
|
||||
throw new Error('upstream failed');
|
||||
},
|
||||
};
|
||||
const service = new AiChatService(
|
||||
{ findOne: jest.fn().mockResolvedValue(conversation) } as never,
|
||||
messages as never,
|
||||
{ save: jest.fn() } as never,
|
||||
{ transaction: jest.fn(async (callback) => callback(manager)) } as never,
|
||||
{ getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
|
||||
{ listAvailable: jest.fn().mockReturnValue([]) } as never,
|
||||
modelStream as never,
|
||||
{
|
||||
requireReadyOwned: jest.fn().mockResolvedValue([]),
|
||||
toModelParts: jest.fn().mockResolvedValue([]),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{
|
||||
createForm: jest.fn(),
|
||||
findOwnedPending: jest.fn(),
|
||||
validateValues: jest.fn(),
|
||||
markSubmitted: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{
|
||||
createReview: jest.fn(),
|
||||
expirePreviousReviews: jest.fn().mockResolvedValue([]),
|
||||
findOwnedPending: jest.fn(),
|
||||
findPendingByAssistantMessage: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
submit: jest.fn(),
|
||||
} as never,
|
||||
{
|
||||
createChart: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
|
||||
);
|
||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
const run = service.streamMessage(
|
||||
authenticatedUser as never,
|
||||
3,
|
||||
{
|
||||
message: '查询',
|
||||
attachmentIds: [],
|
||||
skillKey: null,
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
},
|
||||
abortController.signal,
|
||||
(event, data) => emitted.push({ event, data }),
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
if (abort) await expect(run).resolves.toBeUndefined();
|
||||
else await expect(run).rejects.toThrow('upstream failed');
|
||||
|
||||
expect(messageSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
])(
|
||||
'流中断后保存已生成内容和 $expectedStatus 状态',
|
||||
async ({ abort, expectedStatus, expectedCode }) => {
|
||||
const conversation = {
|
||||
id: 3,
|
||||
userId: 7,
|
||||
title: '测试',
|
||||
lockedSkillKey: null,
|
||||
lastMessageAt: null,
|
||||
};
|
||||
const assistant = {
|
||||
id: 12,
|
||||
content: '部分回答',
|
||||
status: expectedStatus,
|
||||
errorCode: expectedCode,
|
||||
}),
|
||||
);
|
||||
expect(emitted.some(({ event }) => event === 'content.delta')).toBe(true);
|
||||
expect(emitted.some(({ event }) => event === 'message.cancelled')).toBe(abort);
|
||||
});
|
||||
conversationId: 3,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: null,
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
};
|
||||
const messageSave = jest.fn(async (value) => value);
|
||||
const messages = {
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: messageSave,
|
||||
};
|
||||
const manager = {
|
||||
create: jest.fn((_entity, value) => value),
|
||||
save: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '查询' })
|
||||
.mockResolvedValueOnce(assistant),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
const modelStream = {
|
||||
stream: async function* () {
|
||||
yield { type: 'content' as const, delta: '部分回答' };
|
||||
if (abort) {
|
||||
abortController.abort(new Error('client disconnected'));
|
||||
yield { type: 'complete' as const, toolCalls: [] };
|
||||
return;
|
||||
}
|
||||
throw new Error('upstream failed');
|
||||
},
|
||||
};
|
||||
const service = new AiChatService(
|
||||
{ findOne: jest.fn().mockResolvedValue(conversation) } as never,
|
||||
messages as never,
|
||||
{ save: jest.fn() } as never,
|
||||
{ transaction: jest.fn(async (callback) => callback(manager)) } as never,
|
||||
{ getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
|
||||
{ listAvailable: jest.fn().mockReturnValue([]) } as never,
|
||||
modelStream as never,
|
||||
{
|
||||
requireReadyOwned: jest.fn().mockResolvedValue([]),
|
||||
toModelParts: jest.fn().mockResolvedValue([]),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{
|
||||
createForm: jest.fn(),
|
||||
findOwnedPending: jest.fn(),
|
||||
validateValues: jest.fn(),
|
||||
markSubmitted: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{
|
||||
createReview: jest.fn(),
|
||||
expirePreviousReviews: jest.fn().mockResolvedValue([]),
|
||||
findOwnedPending: jest.fn(),
|
||||
findPendingByAssistantMessage: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
submit: jest.fn(),
|
||||
} as never,
|
||||
{
|
||||
createChart: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
|
||||
);
|
||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
const run = service.streamMessage(
|
||||
authenticatedUser as never,
|
||||
3,
|
||||
{
|
||||
message: '查询',
|
||||
attachmentIds: [],
|
||||
skillKey: null,
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
},
|
||||
abortController.signal,
|
||||
(event, data) => emitted.push({ event, data }),
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
if (abort) await expect(run).resolves.toBeUndefined();
|
||||
else await expect(run).rejects.toThrow('upstream failed');
|
||||
|
||||
expect(messageSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 12,
|
||||
content: '部分回答',
|
||||
status: expectedStatus,
|
||||
errorCode: expectedCode,
|
||||
}),
|
||||
);
|
||||
expect(emitted.some(({ event }) => event === 'content.delta')).toBe(true);
|
||||
expect(emitted.some(({ event }) => event === 'message.cancelled')).toBe(abort);
|
||||
},
|
||||
);
|
||||
|
||||
it('普通对话中模型直接调用 create_student 被拒绝', async () => {
|
||||
const { service } = createService();
|
||||
@@ -337,13 +354,15 @@ describe('AiChatService', () => {
|
||||
};
|
||||
(service as unknown as { toolRuns: typeof toolRuns }).toolRuns = toolRuns;
|
||||
const emitted: Array<{ event: string }> = [];
|
||||
const deny = (service as unknown as {
|
||||
denyWriteTool(
|
||||
messageId: number,
|
||||
call: { id: string },
|
||||
emit: (event: string, data: Record<string, unknown>) => void,
|
||||
): Promise<string>;
|
||||
}).denyWriteTool.bind(service);
|
||||
const deny = (
|
||||
service as unknown as {
|
||||
denyWriteTool(
|
||||
messageId: number,
|
||||
call: { id: string },
|
||||
emit: (event: string, data: Record<string, unknown>) => void,
|
||||
): Promise<string>;
|
||||
}
|
||||
).denyWriteTool.bind(service);
|
||||
const payload = await deny(12, { id: 'call-1' }, (event) => emitted.push({ event }));
|
||||
expect(JSON.parse(payload)).toEqual({ status: 'failed', error: '该操作需要表单确认' });
|
||||
expect(emitted).toEqual([{ event: 'tool.failed' }]);
|
||||
@@ -366,8 +385,6 @@ describe('AiChatService', () => {
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
replyToMessageId: 11,
|
||||
feedback: null,
|
||||
feedbackReason: null,
|
||||
metadata: {},
|
||||
};
|
||||
const formShape = {
|
||||
@@ -392,7 +409,12 @@ describe('AiChatService', () => {
|
||||
create: jest.fn((_entity, value) => value),
|
||||
save: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '帮我新增一个学生' })
|
||||
.mockResolvedValueOnce({
|
||||
id: 11,
|
||||
conversationId: 3,
|
||||
role: 'user',
|
||||
content: '帮我新增一个学生',
|
||||
})
|
||||
.mockResolvedValueOnce(assistant),
|
||||
update: jest.fn(),
|
||||
};
|
||||
@@ -497,8 +519,6 @@ describe('AiChatService', () => {
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
replyToMessageId: 11,
|
||||
feedback: null,
|
||||
feedbackReason: null,
|
||||
metadata: {},
|
||||
};
|
||||
const reviewShape = {
|
||||
@@ -523,7 +543,8 @@ describe('AiChatService', () => {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockImplementation((options?: unknown) => {
|
||||
const opts = options as { select?: { metadata?: boolean } } | undefined;
|
||||
if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
|
||||
if (opts?.select?.metadata)
|
||||
return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
|
||||
return Promise.resolve(assistant);
|
||||
}),
|
||||
save: messageSave,
|
||||
@@ -532,7 +553,12 @@ describe('AiChatService', () => {
|
||||
create: jest.fn((_entity, value) => value),
|
||||
save: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '导入这个Excel' })
|
||||
.mockResolvedValueOnce({
|
||||
id: 11,
|
||||
conversationId: 3,
|
||||
role: 'user',
|
||||
content: '导入这个Excel',
|
||||
})
|
||||
.mockResolvedValueOnce(assistant),
|
||||
update: jest.fn(),
|
||||
};
|
||||
@@ -645,8 +671,6 @@ describe('AiChatService', () => {
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
replyToMessageId: 11,
|
||||
feedback: null,
|
||||
feedbackReason: null,
|
||||
metadata: {},
|
||||
};
|
||||
const chartShape = {
|
||||
@@ -668,7 +692,8 @@ describe('AiChatService', () => {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockImplementation((options?: unknown) => {
|
||||
const opts = options as { select?: { metadata?: boolean } } | undefined;
|
||||
if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiChart: [chartShape] } });
|
||||
if (opts?.select?.metadata)
|
||||
return Promise.resolve({ metadata: { a2uiChart: [chartShape] } });
|
||||
return Promise.resolve(assistant);
|
||||
}),
|
||||
save: messageSave,
|
||||
@@ -790,8 +815,6 @@ describe('AiChatService', () => {
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
replyToMessageId: 11,
|
||||
feedback: null,
|
||||
feedbackReason: null,
|
||||
metadata: {},
|
||||
};
|
||||
const messageSave = jest.fn(async (value) => value);
|
||||
@@ -926,8 +949,6 @@ describe('AiChatService', () => {
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
replyToMessageId: 11,
|
||||
feedback: null,
|
||||
feedbackReason: null,
|
||||
metadata: {},
|
||||
};
|
||||
const reviewShape = {
|
||||
@@ -952,7 +973,8 @@ describe('AiChatService', () => {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockImplementation((options?: unknown) => {
|
||||
const opts = options as { select?: { metadata?: boolean } } | undefined;
|
||||
if (opts?.select?.metadata) return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
|
||||
if (opts?.select?.metadata)
|
||||
return Promise.resolve({ metadata: { a2uiReview: reviewShape } });
|
||||
return Promise.resolve(assistant);
|
||||
}),
|
||||
save: messageSave,
|
||||
@@ -961,7 +983,12 @@ describe('AiChatService', () => {
|
||||
create: jest.fn((_entity, value) => value),
|
||||
save: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 11, conversationId: 3, role: 'user', content: '导入这个Excel' })
|
||||
.mockResolvedValueOnce({
|
||||
id: 11,
|
||||
conversationId: 3,
|
||||
role: 'user',
|
||||
content: '导入这个Excel',
|
||||
})
|
||||
.mockResolvedValueOnce(assistant),
|
||||
update: jest.fn(),
|
||||
};
|
||||
@@ -1184,8 +1211,6 @@ describe('AiChatService', () => {
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
replyToMessageId: 11,
|
||||
feedback: null,
|
||||
feedbackReason: null,
|
||||
metadata: { a2uiReview: { id: 'review-1', status: 'pending' } },
|
||||
};
|
||||
const review = {
|
||||
@@ -1207,7 +1232,9 @@ describe('AiChatService', () => {
|
||||
findOne: jest.fn().mockImplementation((options?: unknown) => {
|
||||
const opts = options as { select?: { metadata?: boolean } } | undefined;
|
||||
if (opts?.select?.metadata) {
|
||||
return Promise.resolve({ metadata: { a2uiReview: { id: 'review-1', status: 'submitted' } } });
|
||||
return Promise.resolve({
|
||||
metadata: { a2uiReview: { id: 'review-1', status: 'submitted' } },
|
||||
});
|
||||
}
|
||||
return Promise.resolve(assistant);
|
||||
}),
|
||||
@@ -1302,10 +1329,7 @@ describe('AiChatService', () => {
|
||||
() => order.push('onReady'),
|
||||
);
|
||||
|
||||
expect(assertPermission).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'student:create',
|
||||
);
|
||||
expect(assertPermission).toHaveBeenCalledWith(expect.anything(), 'student:create');
|
||||
expect(submitAll).toHaveBeenCalledTimes(1);
|
||||
expect(emitted[0]).toMatchObject({
|
||||
event: 'ui.review',
|
||||
@@ -1355,7 +1379,11 @@ describe('AiChatService', () => {
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
reviewService.findOwned.mockResolvedValue(review);
|
||||
reviewService.submitSection.mockResolvedValue({ review: updated, result: { created: 1, skipped: 0, issues: [] }, message: '成功导入学生 1 人' });
|
||||
reviewService.submitSection.mockResolvedValue({
|
||||
review: updated,
|
||||
result: { created: 1, skipped: 0, issues: [] },
|
||||
message: '成功导入学生 1 人',
|
||||
});
|
||||
|
||||
const data = await service.confirmReviewStep(
|
||||
authenticatedUser as never,
|
||||
@@ -1364,11 +1392,7 @@ describe('AiChatService', () => {
|
||||
);
|
||||
|
||||
expect(reviewService.findOwned).toHaveBeenCalledWith('review-1', 7);
|
||||
expect(reviewService.submitSection).toHaveBeenCalledWith(
|
||||
'review-1',
|
||||
7,
|
||||
'students',
|
||||
);
|
||||
expect(reviewService.submitSection).toHaveBeenCalledWith('review-1', 7, 'students');
|
||||
expect(data).toMatchObject({ id: 'review-1' });
|
||||
});
|
||||
|
||||
@@ -1462,8 +1486,6 @@ describe('AiChatService', () => {
|
||||
status: 'pending',
|
||||
errorCode: null,
|
||||
replyToMessageId: 11,
|
||||
feedback: null,
|
||||
feedbackReason: null,
|
||||
metadata: {},
|
||||
};
|
||||
const messageSave = jest.fn(async (value) => value);
|
||||
@@ -1580,4 +1602,273 @@ describe('AiChatService', () => {
|
||||
]);
|
||||
expect(emitted.some(({ event }) => event === 'tool.completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('删除用户消息时连同其 AI 回答一起删除并更新会话时间', async () => {
|
||||
const conversation = { id: 3, userId: 7, title: '新对话' };
|
||||
const execute = jest.fn().mockResolvedValue(undefined);
|
||||
const queryBuilder = {
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([{ id: 30 }, { id: 31 }]),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
execute,
|
||||
};
|
||||
const lastMessageAt = new Date('2026-08-04T10:00:00.000Z');
|
||||
const messages = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 10, conversationId: 3, role: 'user' })
|
||||
.mockResolvedValueOnce({ createdAt: lastMessageAt }),
|
||||
find: jest.fn().mockResolvedValue([{ id: 11 }]),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 2 }),
|
||||
};
|
||||
const conversations = {
|
||||
findOne: jest.fn().mockResolvedValue(conversation),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const removeOrphans = jest.fn().mockResolvedValue(undefined);
|
||||
const service = new AiChatService(
|
||||
conversations as never,
|
||||
messages as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ removeOrphans } as never,
|
||||
{
|
||||
createForm: jest.fn(),
|
||||
findOwnedPending: jest.fn(),
|
||||
validateValues: jest.fn(),
|
||||
markSubmitted: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{
|
||||
createReview: jest.fn(),
|
||||
expirePreviousReviews: jest.fn().mockResolvedValue([]),
|
||||
findOwnedPending: jest.fn(),
|
||||
findPendingByAssistantMessage: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
submit: jest.fn(),
|
||||
} as never,
|
||||
{
|
||||
createChart: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
|
||||
);
|
||||
|
||||
await expect(service.deleteMessage(7, 3, 10)).resolves.toEqual({ deletedIds: [10, 11] });
|
||||
expect(messages.delete).toHaveBeenCalledWith([10, 11]);
|
||||
expect(execute).toHaveBeenCalled();
|
||||
expect(removeOrphans).toHaveBeenCalledWith(7, [30, 31]);
|
||||
expect(conversations.update).toHaveBeenCalledWith({ id: 3, userId: 7 }, { lastMessageAt });
|
||||
});
|
||||
|
||||
it('生成中的会话禁止删除单条消息', async () => {
|
||||
const service = new AiChatService(
|
||||
{ findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
createForm: jest.fn(),
|
||||
findOwnedPending: jest.fn(),
|
||||
validateValues: jest.fn(),
|
||||
markSubmitted: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{
|
||||
createReview: jest.fn(),
|
||||
expirePreviousReviews: jest.fn().mockResolvedValue([]),
|
||||
findOwnedPending: jest.fn(),
|
||||
findPendingByAssistantMessage: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
submit: jest.fn(),
|
||||
} as never,
|
||||
{
|
||||
createChart: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
|
||||
);
|
||||
(service as unknown as { activeConversations: Set<number> }).activeConversations.add(3);
|
||||
await expect(service.deleteMessage(7, 3, 10)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('编辑用户消息后截断后续消息并重新生成回答', async () => {
|
||||
const conversation = { id: 3, userId: 7, title: '旧问题' };
|
||||
const target = {
|
||||
id: 10,
|
||||
conversationId: 3,
|
||||
role: 'user',
|
||||
status: 'completed',
|
||||
content: '旧问题',
|
||||
metadata: null,
|
||||
attachments: [],
|
||||
};
|
||||
const assistant = { id: 13, conversationId: 3, role: 'assistant' };
|
||||
const manager = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
find: jest.fn().mockResolvedValue([{ id: 11 }, { id: 12 }]),
|
||||
createQueryBuilder: jest.fn().mockReturnValue({
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 2 }),
|
||||
create: jest.fn((_entity, value) => value),
|
||||
save: jest.fn().mockResolvedValue(assistant),
|
||||
};
|
||||
const messages = {
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
findOne: jest.fn().mockResolvedValue(target),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const conversations = {
|
||||
findOne: jest.fn().mockResolvedValue(conversation),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const toolRuns = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const modelStream = {
|
||||
stream: async function* () {
|
||||
yield { type: 'complete' as const, toolCalls: [] };
|
||||
},
|
||||
};
|
||||
const removeOrphans = jest.fn().mockResolvedValue(undefined);
|
||||
const service = new AiChatService(
|
||||
conversations as never,
|
||||
messages as never,
|
||||
toolRuns as never,
|
||||
{ transaction: jest.fn(async (callback) => callback(manager)) } as never,
|
||||
{ getRuntimeConfig: jest.fn().mockResolvedValue({ supportsVision: false }) } as never,
|
||||
{ listAvailable: jest.fn().mockReturnValue([]) } as never,
|
||||
modelStream as never,
|
||||
{ removeOrphans } as never,
|
||||
{
|
||||
createForm: jest.fn(),
|
||||
findOwnedPending: jest.fn(),
|
||||
validateValues: jest.fn(),
|
||||
markSubmitted: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{
|
||||
createReview: jest.fn(),
|
||||
expirePreviousReviews: jest.fn().mockResolvedValue([]),
|
||||
findOwnedPending: jest.fn(),
|
||||
findPendingByAssistantMessage: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
submit: jest.fn(),
|
||||
} as never,
|
||||
{
|
||||
createChart: jest.fn(),
|
||||
serialize: jest.fn((value) => value),
|
||||
} as never,
|
||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } as never,
|
||||
);
|
||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await service.editMessage(
|
||||
authenticatedUser as never,
|
||||
3,
|
||||
10,
|
||||
{
|
||||
content: '新问题',
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
},
|
||||
new AbortController().signal,
|
||||
(event, data) => emitted.push({ event, data }),
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
expect(manager.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: 10, conversationId: 3 },
|
||||
expect.objectContaining({ content: '新问题' }),
|
||||
);
|
||||
expect(manager.delete).toHaveBeenCalledWith(expect.anything(), [11, 12]);
|
||||
expect(conversations.update).toHaveBeenCalledWith(
|
||||
{ id: 3, userId: 7 },
|
||||
expect.objectContaining({ title: '新问题' }),
|
||||
);
|
||||
expect(emitted.some(({ event }) => event === 'message.completed')).toBe(true);
|
||||
expect(removeOrphans).toHaveBeenCalledWith(7, []);
|
||||
});
|
||||
|
||||
it('start_import_wizard 阶段缺少 sheet 时失败,且不创建导入任务', async () => {
|
||||
const { service } = createService();
|
||||
const toolRun = { id: 1, status: 'running' };
|
||||
const toolRuns = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
||||
};
|
||||
const messages = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 42, metadata: null }),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const attachmentService = {
|
||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 9,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
originalName: 'students.xlsx',
|
||||
},
|
||||
]),
|
||||
readStoredBuffer: jest.fn(),
|
||||
};
|
||||
const importsService = { createRun: jest.fn() };
|
||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
||||
(service as unknown as { messages: unknown }).messages = messages;
|
||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
||||
|
||||
const emitted: Array<{ event: string }> = [];
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
executeStartImportWizard(
|
||||
messageId: number,
|
||||
call: { id: string; name: string; arguments: string },
|
||||
context: { userId: number; permissions: string[]; isSuperAdmin: boolean },
|
||||
emit: (event: string, data?: unknown) => void,
|
||||
): Promise<string>;
|
||||
}
|
||||
).executeStartImportWizard(
|
||||
42,
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'start_import_wizard',
|
||||
arguments: JSON.stringify({
|
||||
attachmentId: 9,
|
||||
stages: [{ stepKey: 'students' }],
|
||||
}),
|
||||
},
|
||||
{ userId: 7, permissions: [], isSuperAdmin: false },
|
||||
(event) => emitted.push({ event }),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result) as { status: string; error: string };
|
||||
expect(parsed.status).toBe('failed');
|
||||
expect(parsed.error).toContain('缺少工作表 sheet');
|
||||
expect(importsService.createRun).not.toHaveBeenCalled();
|
||||
expect(emitted.some(({ event }) => event === 'tool.failed')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user