mcp
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ListsModule } from '../lists/lists.module';
|
||||
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
||||
import { AssistantController } from './assistant.controller';
|
||||
import { AssistantService } from './assistant.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TypeOrmModule.forFeature([AssistantChatLogEntity])],
|
||||
imports: [
|
||||
AuthModule,
|
||||
ListsModule,
|
||||
TypeOrmModule.forFeature([AssistantChatLogEntity]),
|
||||
],
|
||||
controllers: [AssistantController],
|
||||
providers: [AssistantService],
|
||||
})
|
||||
|
||||
@@ -9,6 +9,9 @@ describe('AssistantService', () => {
|
||||
create: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
let listRealtimeService: {
|
||||
publishSnapshot: jest.Mock;
|
||||
};
|
||||
let service: AssistantService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -19,7 +22,13 @@ describe('AssistantService', () => {
|
||||
create: jest.fn((input) => input),
|
||||
save: jest.fn(async (input) => input),
|
||||
};
|
||||
service = new AssistantService(chatLogsRepository as never);
|
||||
listRealtimeService = {
|
||||
publishSnapshot: jest.fn(),
|
||||
};
|
||||
service = new AssistantService(
|
||||
chatLogsRepository as never,
|
||||
listRealtimeService as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,6 +95,7 @@ describe('AssistantService', () => {
|
||||
},
|
||||
actions: [],
|
||||
});
|
||||
expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled();
|
||||
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
@@ -129,6 +139,27 @@ describe('AssistantService', () => {
|
||||
});
|
||||
|
||||
it('extracts the final assistant message from multi-completion responses', async () => {
|
||||
const updatedList = {
|
||||
id: 'list-1',
|
||||
ownerId: 'user-1',
|
||||
accessRole: 'owner',
|
||||
name: 'Einkauf',
|
||||
kind: 'shopping',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
title: 'Milch',
|
||||
required: true,
|
||||
checked: false,
|
||||
position: 0,
|
||||
createdAt: '2026-06-12T00:00:00.000Z',
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
collaborators: [],
|
||||
createdAt: '2026-06-12T00:00:00.000Z',
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
};
|
||||
const providerResponse = {
|
||||
id: 'chatcmpl-test',
|
||||
object: 'chat.multi_completion',
|
||||
@@ -144,8 +175,8 @@ describe('AssistantService', () => {
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'listify_list_existing_lists',
|
||||
arguments: '{"includeItems": false}',
|
||||
name: 'listify_add_list_item',
|
||||
arguments: '{"listId": "list-1", "title": "Milch"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -154,6 +185,14 @@ describe('AssistantService', () => {
|
||||
role: 'tool',
|
||||
index: 1,
|
||||
content: [{ type: 'text', text: '{"lists":[]}' }],
|
||||
tool_call_id: 'call-1',
|
||||
metadata: {
|
||||
mcp_meta: {
|
||||
structuredContent: {
|
||||
list: updatedList,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -172,6 +211,18 @@ describe('AssistantService', () => {
|
||||
});
|
||||
|
||||
expect(result.message.content).toBe('Hier sind deine bestehenden Listen.');
|
||||
expect(result.actions).toEqual([
|
||||
{
|
||||
type: 'list.item_added',
|
||||
listId: 'list-1',
|
||||
itemTitle: 'Milch',
|
||||
list: updatedList,
|
||||
},
|
||||
]);
|
||||
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
updatedList,
|
||||
);
|
||||
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
responsePayload: providerResponse,
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserList } from '../list-templates/list-template.types';
|
||||
import { ListRealtimeService } from '../lists/list-realtime.service';
|
||||
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
||||
import {
|
||||
AssistantAction,
|
||||
AssistantChatMessage,
|
||||
AssistantChatRequest,
|
||||
AssistantChatResponse,
|
||||
@@ -21,6 +24,18 @@ interface MistralAgentCompletionResponse {
|
||||
messages?: Array<{
|
||||
role?: string;
|
||||
content?: string | null | unknown[];
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
};
|
||||
}>;
|
||||
metadata?: {
|
||||
mcp_meta?: {
|
||||
structuredContent?: unknown;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
@@ -32,6 +47,7 @@ export class AssistantService {
|
||||
constructor(
|
||||
@InjectRepository(AssistantChatLogEntity)
|
||||
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
|
||||
private readonly listRealtimeService: ListRealtimeService,
|
||||
) {}
|
||||
|
||||
async chat(
|
||||
@@ -41,6 +57,11 @@ export class AssistantService {
|
||||
const messages = this.normalizeMessages(request.messages);
|
||||
const response = await this.callMistralAgent(userId, messages);
|
||||
const content = this.extractAssistantContent(response);
|
||||
const actions = this.extractActions(response);
|
||||
|
||||
actions.forEach((action) => {
|
||||
this.listRealtimeService.publishSnapshot(userId, action.list);
|
||||
});
|
||||
|
||||
if (!content) {
|
||||
throw new ServiceUnavailableException('Mistral response was empty.');
|
||||
@@ -51,7 +72,7 @@ export class AssistantService {
|
||||
role: 'assistant',
|
||||
content,
|
||||
},
|
||||
actions: [],
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,6 +198,116 @@ export class AssistantService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractActions(responsePayload: unknown): AssistantAction[] {
|
||||
if (!responsePayload || typeof responsePayload !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions: AssistantAction[] = [];
|
||||
const response = responsePayload as MistralAgentCompletionResponse;
|
||||
|
||||
for (const choice of response.choices ?? []) {
|
||||
const toolNamesById = new Map<string, string>();
|
||||
|
||||
for (const message of choice.messages ?? []) {
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
if (toolCall.id && toolCall.function?.name) {
|
||||
toolNamesById.set(toolCall.id, toolCall.function.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of choice.messages ?? []) {
|
||||
if (message.role !== 'tool') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const structuredContent = message.metadata?.mcp_meta?.structuredContent;
|
||||
const list = this.listFromStructuredContent(structuredContent);
|
||||
|
||||
if (!list) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolName = message.tool_call_id
|
||||
? toolNamesById.get(message.tool_call_id)
|
||||
: undefined;
|
||||
|
||||
if (toolName?.includes('create_list')) {
|
||||
actions.push({
|
||||
type: 'list.created',
|
||||
listId: list.id,
|
||||
list,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolName?.includes('add_list_item')) {
|
||||
actions.push({
|
||||
type: 'list.item_added',
|
||||
listId: list.id,
|
||||
itemTitle: this.lastItemTitle(list),
|
||||
list,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push({
|
||||
type: 'list.item_added',
|
||||
listId: list.id,
|
||||
itemTitle: this.lastItemTitle(list),
|
||||
list,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.uniqueActions(actions);
|
||||
}
|
||||
|
||||
private listFromStructuredContent(value: unknown): UserList | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const structuredContent = value as { list?: unknown };
|
||||
const list = structuredContent.list;
|
||||
|
||||
if (!list || typeof list !== 'object' || Array.isArray(list)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = list as Partial<UserList>;
|
||||
|
||||
if (
|
||||
typeof candidate.id !== 'string' ||
|
||||
typeof candidate.name !== 'string' ||
|
||||
!Array.isArray(candidate.items)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate as UserList;
|
||||
}
|
||||
|
||||
private lastItemTitle(list: UserList): string {
|
||||
return list.items.at(-1)?.title ?? 'Eintrag';
|
||||
}
|
||||
|
||||
private uniqueActions(actions: AssistantAction[]): AssistantAction[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return actions.filter((action) => {
|
||||
const key = `${action.type}:${action.listId}:${action.type === 'list.item_added' ? action.itemTitle : ''}`;
|
||||
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async recordChatLog(input: {
|
||||
userId: string;
|
||||
agentId: string;
|
||||
|
||||
Reference in New Issue
Block a user