This commit is contained in:
Bastian Wagner
2026-06-12 15:07:16 +02:00
parent a921095f3a
commit 6642575ea9
3 changed files with 192 additions and 5 deletions

View File

@@ -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],
}) })

View File

@@ -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,

View File

@@ -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;