mcp
This commit is contained in:
@@ -1,12 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ListsModule } from '../lists/lists.module';
|
||||||
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
||||||
import { AssistantController } from './assistant.controller';
|
import { AssistantController } from './assistant.controller';
|
||||||
import { AssistantService } from './assistant.service';
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TypeOrmModule.forFeature([AssistantChatLogEntity])],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
ListsModule,
|
||||||
|
TypeOrmModule.forFeature([AssistantChatLogEntity]),
|
||||||
|
],
|
||||||
controllers: [AssistantController],
|
controllers: [AssistantController],
|
||||||
providers: [AssistantService],
|
providers: [AssistantService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ describe('AssistantService', () => {
|
|||||||
create: jest.Mock;
|
create: jest.Mock;
|
||||||
save: jest.Mock;
|
save: jest.Mock;
|
||||||
};
|
};
|
||||||
|
let listRealtimeService: {
|
||||||
|
publishSnapshot: jest.Mock;
|
||||||
|
};
|
||||||
let service: AssistantService;
|
let service: AssistantService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -19,7 +22,13 @@ describe('AssistantService', () => {
|
|||||||
create: jest.fn((input) => input),
|
create: jest.fn((input) => input),
|
||||||
save: jest.fn(async (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(() => {
|
afterEach(() => {
|
||||||
@@ -86,6 +95,7 @@ describe('AssistantService', () => {
|
|||||||
},
|
},
|
||||||
actions: [],
|
actions: [],
|
||||||
});
|
});
|
||||||
|
expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled();
|
||||||
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
@@ -129,6 +139,27 @@ describe('AssistantService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('extracts the final assistant message from multi-completion responses', async () => {
|
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 = {
|
const providerResponse = {
|
||||||
id: 'chatcmpl-test',
|
id: 'chatcmpl-test',
|
||||||
object: 'chat.multi_completion',
|
object: 'chat.multi_completion',
|
||||||
@@ -144,8 +175,8 @@ describe('AssistantService', () => {
|
|||||||
id: 'call-1',
|
id: 'call-1',
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'listify_list_existing_lists',
|
name: 'listify_add_list_item',
|
||||||
arguments: '{"includeItems": false}',
|
arguments: '{"listId": "list-1", "title": "Milch"}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -154,6 +185,14 @@ describe('AssistantService', () => {
|
|||||||
role: 'tool',
|
role: 'tool',
|
||||||
index: 1,
|
index: 1,
|
||||||
content: [{ type: 'text', text: '{"lists":[]}' }],
|
content: [{ type: 'text', text: '{"lists":[]}' }],
|
||||||
|
tool_call_id: 'call-1',
|
||||||
|
metadata: {
|
||||||
|
mcp_meta: {
|
||||||
|
structuredContent: {
|
||||||
|
list: updatedList,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -172,6 +211,18 @@ describe('AssistantService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.message.content).toBe('Hier sind deine bestehenden Listen.');
|
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(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
responsePayload: providerResponse,
|
responsePayload: providerResponse,
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { Repository } from 'typeorm';
|
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 { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
||||||
import {
|
import {
|
||||||
|
AssistantAction,
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantChatRequest,
|
AssistantChatRequest,
|
||||||
AssistantChatResponse,
|
AssistantChatResponse,
|
||||||
@@ -21,6 +24,18 @@ interface MistralAgentCompletionResponse {
|
|||||||
messages?: Array<{
|
messages?: Array<{
|
||||||
role?: string;
|
role?: string;
|
||||||
content?: string | null | unknown[];
|
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(
|
constructor(
|
||||||
@InjectRepository(AssistantChatLogEntity)
|
@InjectRepository(AssistantChatLogEntity)
|
||||||
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
|
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
|
||||||
|
private readonly listRealtimeService: ListRealtimeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
@@ -41,6 +57,11 @@ export class AssistantService {
|
|||||||
const messages = this.normalizeMessages(request.messages);
|
const messages = this.normalizeMessages(request.messages);
|
||||||
const response = await this.callMistralAgent(userId, messages);
|
const response = await this.callMistralAgent(userId, messages);
|
||||||
const content = this.extractAssistantContent(response);
|
const content = this.extractAssistantContent(response);
|
||||||
|
const actions = this.extractActions(response);
|
||||||
|
|
||||||
|
actions.forEach((action) => {
|
||||||
|
this.listRealtimeService.publishSnapshot(userId, action.list);
|
||||||
|
});
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw new ServiceUnavailableException('Mistral response was empty.');
|
throw new ServiceUnavailableException('Mistral response was empty.');
|
||||||
@@ -51,7 +72,7 @@ export class AssistantService {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content,
|
content,
|
||||||
},
|
},
|
||||||
actions: [],
|
actions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +198,116 @@ export class AssistantService {
|
|||||||
return null;
|
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: {
|
private async recordChatLog(input: {
|
||||||
userId: string;
|
userId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user