mcp
This commit is contained in:
@@ -19,7 +19,7 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
CLIENT_URL=http://localhost:8080
|
CLIENT_URL=http://localhost:8080
|
||||||
|
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
MISTRAL_MODEL=mistral-small-latest
|
MISTRAL_AGENT_ID=
|
||||||
|
|
||||||
MAIL_ENABLED=true
|
MAIL_ENABLED=true
|
||||||
SMTP_HOST=host.docker.internal
|
SMTP_HOST=host.docker.internal
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
CLIENT_URL=http://localhost:4200
|
CLIENT_URL=http://localhost:4200
|
||||||
|
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
MISTRAL_MODEL=mistral-small-latest
|
MISTRAL_AGENT_ID=
|
||||||
|
|
||||||
MAIL_ENABLED=true
|
MAIL_ENABLED=true
|
||||||
SMTP_HOST=localhost
|
SMTP_HOST=localhost
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ $ npm run start:prod
|
|||||||
|
|
||||||
## Mistral assistant
|
## Mistral assistant
|
||||||
|
|
||||||
The in-app assistant calls Mistral from the API server. Configure the key in the API environment, never in the Angular client:
|
The in-app assistant calls a configured Mistral agent from the API server. Configure the key and agent id in the API environment, never in the Angular client:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MISTRAL_API_KEY=your-mistral-api-key
|
MISTRAL_API_KEY=your-mistral-api-key
|
||||||
MISTRAL_MODEL=mistral-small-latest
|
MISTRAL_AGENT_ID=your-mistral-agent-id
|
||||||
```
|
```
|
||||||
|
|
||||||
The authenticated frontend calls `POST /api/assistant/chat`; the API then calls Mistral and may execute allowed Listify actions such as creating lists or adding list items.
|
The Mistral agent should have the `listify` connector attached. The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/agents/completions` and returns the assistant text. Listify does not parse or execute tool calls locally in this path.
|
||||||
|
|
||||||
## Run tests
|
## Run tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ListsModule } from '../lists/lists.module';
|
|
||||||
import { AssistantController } from './assistant.controller';
|
import { AssistantController } from './assistant.controller';
|
||||||
import { AssistantService } from './assistant.service';
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, ListsModule],
|
imports: [AuthModule],
|
||||||
controllers: [AssistantController],
|
controllers: [AssistantController],
|
||||||
providers: [AssistantService],
|
providers: [AssistantService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,170 +1,71 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { UserList } from '../list-templates/list-template.types';
|
|
||||||
import { ListsService } from '../lists/lists.service';
|
|
||||||
import { AssistantService } from './assistant.service';
|
import { AssistantService } from './assistant.service';
|
||||||
|
|
||||||
describe('AssistantService', () => {
|
describe('AssistantService', () => {
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
const originalApiKey = process.env.MISTRAL_API_KEY;
|
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||||
let listsService: Pick<ListsService, 'listLists' | 'createList' | 'addItem'>;
|
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
||||||
let service: AssistantService;
|
let service: AssistantService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.MISTRAL_API_KEY = 'test-key';
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
|
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
||||||
global.fetch = jest.fn();
|
global.fetch = jest.fn();
|
||||||
listsService = {
|
service = new AssistantService();
|
||||||
listLists: jest.fn(),
|
|
||||||
createList: jest.fn(),
|
|
||||||
addItem: jest.fn(),
|
|
||||||
};
|
|
||||||
service = new AssistantService(listsService as ListsService);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
process.env.MISTRAL_API_KEY = originalApiKey;
|
process.env.MISTRAL_API_KEY = originalApiKey;
|
||||||
delete process.env.MISTRAL_MODEL;
|
process.env.MISTRAL_AGENT_ID = originalAgentId;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a plain assistant response without tool calls', async () => {
|
it('forwards messages to the configured Mistral agent', async () => {
|
||||||
mockMistralResponse({
|
mockMistralResponse({
|
||||||
choices: [{ message: { content: 'Klar, ich helfe dir.' } }],
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: 'Ich habe den Listify-Connector verwendet.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.chat('user-1', {
|
const result = await service.chat('user-1', {
|
||||||
messages: [{ role: 'user', content: 'Hallo' }],
|
messages: [
|
||||||
|
{ role: 'assistant', content: 'Hallo' },
|
||||||
|
{ role: 'user', content: 'Erstelle eine Liste' },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.mistral.ai/v1/agents/completions',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer test-key',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_id: 'agent-listify',
|
||||||
|
messages: [
|
||||||
|
{ role: 'assistant', content: 'Hallo' },
|
||||||
|
{ role: 'user', content: 'Erstelle eine Liste' },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
response_format: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
message: { role: 'assistant', content: 'Klar, ich helfe dir.' },
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Ich habe den Listify-Connector verwendet.',
|
||||||
|
},
|
||||||
actions: [],
|
actions: [],
|
||||||
});
|
});
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('executes create_list tool calls and returns the final assistant response', async () => {
|
|
||||||
const createdList = list({ id: 'list-1', name: 'Sommerurlaub' });
|
|
||||||
const completedList = list({
|
|
||||||
id: 'list-1',
|
|
||||||
name: 'Sommerurlaub',
|
|
||||||
items: ['Pass'],
|
|
||||||
});
|
|
||||||
jest.mocked(listsService.createList).mockResolvedValue(createdList);
|
|
||||||
jest.mocked(listsService.addItem).mockResolvedValue(completedList);
|
|
||||||
mockMistralResponse(
|
|
||||||
{
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
content: '',
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
id: 'call-1',
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: 'create_list',
|
|
||||||
arguments: JSON.stringify({
|
|
||||||
name: 'Sommerurlaub',
|
|
||||||
kind: 'packing',
|
|
||||||
items: [{ title: 'Pass' }],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
content: 'Ich habe die Packliste Sommerurlaub erstellt.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.chat('user-1', {
|
|
||||||
messages: [{ role: 'user', content: 'Erstelle eine Packliste' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(listsService.createList).toHaveBeenCalledWith('user-1', {
|
|
||||||
name: 'Sommerurlaub',
|
|
||||||
description: undefined,
|
|
||||||
kind: 'packing',
|
|
||||||
});
|
|
||||||
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
|
|
||||||
title: 'Pass',
|
|
||||||
notes: undefined,
|
|
||||||
quantity: undefined,
|
|
||||||
required: undefined,
|
|
||||||
});
|
|
||||||
expect(result.actions).toEqual([
|
|
||||||
{
|
|
||||||
type: 'list.created',
|
|
||||||
listId: 'list-1',
|
|
||||||
list: completedList,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(result.message.content).toBe(
|
|
||||||
'Ich habe die Packliste Sommerurlaub erstellt.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('executes add_list_item tool calls', async () => {
|
|
||||||
const updatedList = list({
|
|
||||||
id: 'list-1',
|
|
||||||
name: 'Einkauf',
|
|
||||||
items: ['Milch'],
|
|
||||||
});
|
|
||||||
jest.mocked(listsService.addItem).mockResolvedValue(updatedList);
|
|
||||||
mockMistralResponse(
|
|
||||||
{
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
content: '',
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
id: 'call-1',
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: 'add_list_item',
|
|
||||||
arguments: JSON.stringify({
|
|
||||||
listId: 'list-1',
|
|
||||||
title: 'Milch',
|
|
||||||
quantity: 2,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
choices: [{ message: { content: 'Milch wurde hinzugefuegt.' } }],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.chat('user-1', {
|
|
||||||
messages: [{ role: 'user', content: 'Fuege Milch hinzu' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', {
|
|
||||||
title: 'Milch',
|
|
||||||
notes: undefined,
|
|
||||||
quantity: 2,
|
|
||||||
required: undefined,
|
|
||||||
});
|
|
||||||
expect(result.actions[0]).toEqual({
|
|
||||||
type: 'list.item_added',
|
|
||||||
listId: 'list-1',
|
|
||||||
itemTitle: 'Milch',
|
|
||||||
list: updatedList,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails clearly when the api key is missing', async () => {
|
it('fails clearly when the api key is missing', async () => {
|
||||||
@@ -176,45 +77,21 @@ describe('AssistantService', () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow(ServiceUnavailableException);
|
).rejects.toThrow(ServiceUnavailableException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails clearly when the agent id is missing', async () => {
|
||||||
|
delete process.env.MISTRAL_AGENT_ID;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.chat('user-1', {
|
||||||
|
messages: [{ role: 'user', content: 'Hallo' }],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(ServiceUnavailableException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockMistralResponse(...responses: object[]): void {
|
function mockMistralResponse(response: object): void {
|
||||||
jest.mocked(global.fetch).mockImplementation(async () => {
|
jest.mocked(global.fetch).mockResolvedValue({
|
||||||
const response = responses.shift() ?? responses[responses.length - 1];
|
ok: true,
|
||||||
|
json: async () => response,
|
||||||
return {
|
} as Response);
|
||||||
ok: true,
|
|
||||||
json: async () => response,
|
|
||||||
} as Response;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function list(options: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
items?: string[];
|
|
||||||
}): UserList {
|
|
||||||
return {
|
|
||||||
id: options.id,
|
|
||||||
ownerId: 'user-1',
|
|
||||||
accessRole: 'owner',
|
|
||||||
name: options.name,
|
|
||||||
kind: 'custom',
|
|
||||||
items: (options.items ?? []).map((title, position) => ({
|
|
||||||
id: `item-${position}`,
|
|
||||||
title,
|
|
||||||
required: true,
|
|
||||||
checked: false,
|
|
||||||
position,
|
|
||||||
createdAt: now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
})),
|
|
||||||
collaborators: [],
|
|
||||||
createdAt: now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function now(): string {
|
|
||||||
return new Date(0).toISOString();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,186 +3,59 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ListTemplateKind } from '../list-templates/list-template.types';
|
|
||||||
import { ListsService } from '../lists/lists.service';
|
|
||||||
import {
|
import {
|
||||||
AddListItemToolInput,
|
|
||||||
AssistantAction,
|
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantChatRequest,
|
AssistantChatRequest,
|
||||||
AssistantChatResponse,
|
AssistantChatResponse,
|
||||||
CreateListToolInput,
|
|
||||||
} from './assistant.types';
|
} from './assistant.types';
|
||||||
|
|
||||||
type MistralRole = 'system' | 'user' | 'assistant' | 'tool';
|
interface MistralAgentCompletionResponse {
|
||||||
|
|
||||||
interface MistralMessage {
|
|
||||||
role: MistralRole;
|
|
||||||
content: string | null;
|
|
||||||
tool_call_id?: string;
|
|
||||||
tool_calls?: MistralToolCall[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MistralToolCall {
|
|
||||||
id: string;
|
|
||||||
type: 'function';
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
arguments: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MistralChatResponse {
|
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
tool_calls?: MistralToolCall[];
|
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssistantService {
|
export class AssistantService {
|
||||||
private readonly endpoint = 'https://api.mistral.ai/v1/chat/completions';
|
private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions';
|
||||||
|
|
||||||
constructor(private readonly listsService: ListsService) {}
|
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
userId: string,
|
_userId: string,
|
||||||
request: AssistantChatRequest,
|
request: AssistantChatRequest,
|
||||||
): Promise<AssistantChatResponse> {
|
): Promise<AssistantChatResponse> {
|
||||||
const messages = this.normalizeMessages(request.messages);
|
const messages = this.normalizeMessages(request.messages);
|
||||||
const firstResponse = await this.callMistral([
|
const response = await this.callMistralAgent(messages);
|
||||||
this.systemMessage(),
|
const content = response.choices?.[0]?.message?.content?.trim();
|
||||||
...messages.map((message) => ({
|
|
||||||
role: message.role,
|
|
||||||
content: message.content,
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
const firstMessage = this.extractMessage(firstResponse);
|
|
||||||
const actions: AssistantAction[] = [];
|
|
||||||
const toolCalls = firstMessage.tool_calls ?? [];
|
|
||||||
|
|
||||||
if (toolCalls.length === 0) {
|
if (!content) {
|
||||||
return {
|
throw new ServiceUnavailableException('Mistral response was empty.');
|
||||||
message: {
|
|
||||||
role: 'assistant',
|
|
||||||
content: this.normalizeAssistantContent(firstMessage.content),
|
|
||||||
},
|
|
||||||
actions,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolMessages: MistralMessage[] = [];
|
|
||||||
|
|
||||||
for (const toolCall of toolCalls) {
|
|
||||||
const toolResult = await this.executeToolCall(userId, toolCall, actions);
|
|
||||||
toolMessages.push({
|
|
||||||
role: 'tool',
|
|
||||||
tool_call_id: toolCall.id,
|
|
||||||
content: JSON.stringify(toolResult),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalResponse = await this.callMistral([
|
|
||||||
this.systemMessage(),
|
|
||||||
...messages.map((message) => ({
|
|
||||||
role: message.role,
|
|
||||||
content: message.content,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: firstMessage.content ?? '',
|
|
||||||
tool_calls: toolCalls,
|
|
||||||
},
|
|
||||||
...toolMessages,
|
|
||||||
]);
|
|
||||||
const finalMessage = this.extractMessage(finalResponse);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: this.normalizeAssistantContent(finalMessage.content),
|
content,
|
||||||
},
|
},
|
||||||
actions,
|
actions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeToolCall(
|
private async callMistralAgent(
|
||||||
userId: string,
|
messages: AssistantChatMessage[],
|
||||||
toolCall: MistralToolCall,
|
): Promise<MistralAgentCompletionResponse> {
|
||||||
actions: AssistantAction[],
|
|
||||||
): Promise<object> {
|
|
||||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
|
||||||
|
|
||||||
if (toolCall.function.name === 'list_existing_lists') {
|
|
||||||
return {
|
|
||||||
lists: (await this.listsService.listLists(userId)).map((list) => ({
|
|
||||||
id: list.id,
|
|
||||||
name: list.name,
|
|
||||||
description: list.description,
|
|
||||||
kind: list.kind,
|
|
||||||
itemCount: list.items.length,
|
|
||||||
items: list.items.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
required: item.required,
|
|
||||||
checked: item.checked,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toolCall.function.name === 'create_list') {
|
|
||||||
const input = this.normalizeCreateListInput(args);
|
|
||||||
let list = await this.listsService.createList(userId, {
|
|
||||||
name: input.name!,
|
|
||||||
description: input.description,
|
|
||||||
kind: input.kind,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const item of input.items ?? []) {
|
|
||||||
list = await this.listsService.addItem(userId, list.id, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
actions.push({
|
|
||||||
type: 'list.created',
|
|
||||||
listId: list.id,
|
|
||||||
list,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { list };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toolCall.function.name === 'add_list_item') {
|
|
||||||
const input = this.normalizeAddListItemInput(args);
|
|
||||||
const list = await this.listsService.addItem(userId, input.listId!, {
|
|
||||||
title: input.title!,
|
|
||||||
notes: input.notes,
|
|
||||||
quantity: input.quantity,
|
|
||||||
required: input.required,
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.push({
|
|
||||||
type: 'list.item_added',
|
|
||||||
listId: list.id,
|
|
||||||
itemTitle: input.title!,
|
|
||||||
list,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { list };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: `Unsupported tool: ${toolCall.function.name}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async callMistral(messages: MistralMessage[]) {
|
|
||||||
const apiKey = process.env.MISTRAL_API_KEY;
|
const apiKey = process.env.MISTRAL_API_KEY;
|
||||||
|
const agentId = process.env.MISTRAL_AGENT_ID;
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new ServiceUnavailableException('Mistral API key is not configured.');
|
throw new ServiceUnavailableException('Mistral API key is not configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!agentId) {
|
||||||
|
throw new ServiceUnavailableException('Mistral agent id is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(this.endpoint, {
|
const response = await fetch(this.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -190,18 +63,20 @@ export class AssistantService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: process.env.MISTRAL_MODEL ?? 'mistral-small-latest',
|
agent_id: agentId,
|
||||||
messages,
|
messages,
|
||||||
tools: this.tools(),
|
stream: false,
|
||||||
tool_choice: 'auto',
|
response_format: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ServiceUnavailableException('Mistral API request failed.');
|
throw new ServiceUnavailableException('Mistral agent request failed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.json()) as MistralChatResponse;
|
return (await response.json()) as MistralAgentCompletionResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMessages(
|
private normalizeMessages(
|
||||||
@@ -225,205 +100,4 @@ export class AssistantService {
|
|||||||
return { role: message.role, content };
|
return { role: message.role, content };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeCreateListInput(value: unknown): CreateListToolInput {
|
|
||||||
const input = this.objectValue(value);
|
|
||||||
const name = this.requiredString(input.name, 'name');
|
|
||||||
const description = this.optionalString(input.description);
|
|
||||||
const kind = this.optionalKind(input.kind);
|
|
||||||
const rawItems = input.items;
|
|
||||||
|
|
||||||
if (rawItems !== undefined && !Array.isArray(rawItems)) {
|
|
||||||
throw new BadRequestException('items must be an array.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
kind,
|
|
||||||
items: rawItems
|
|
||||||
?.slice(0, 50)
|
|
||||||
.map((item) => this.normalizeAddListItemInput(item, false)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeAddListItemInput(
|
|
||||||
value: unknown,
|
|
||||||
requireListId = true,
|
|
||||||
): AddListItemToolInput {
|
|
||||||
const input = this.objectValue(value);
|
|
||||||
const title = this.requiredString(input.title, 'title');
|
|
||||||
const listId = requireListId
|
|
||||||
? this.requiredString(input.listId, 'listId')
|
|
||||||
: undefined;
|
|
||||||
const notes = this.optionalString(input.notes);
|
|
||||||
const quantity =
|
|
||||||
input.quantity === undefined ? undefined : Number(input.quantity);
|
|
||||||
|
|
||||||
if (
|
|
||||||
quantity !== undefined &&
|
|
||||||
(!Number.isFinite(quantity) || quantity <= 0)
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('quantity must be greater than zero.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
input.required !== undefined &&
|
|
||||||
typeof input.required !== 'boolean'
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('required must be a boolean.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
listId,
|
|
||||||
title,
|
|
||||||
notes,
|
|
||||||
quantity,
|
|
||||||
required: input.required,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseToolArguments(value: string): unknown {
|
|
||||||
try {
|
|
||||||
return value ? (JSON.parse(value) as unknown) : {};
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException('Tool arguments must be valid JSON.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private objectValue(value: unknown): Record<string, unknown> {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
||||||
throw new BadRequestException('Tool arguments must be an object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requiredString(value: unknown, field: string): string {
|
|
||||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
throw new BadRequestException(`${field} is required.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private optionalString(value: unknown): string | undefined {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
||||||
return normalized || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private optionalKind(value: unknown): ListTemplateKind | undefined {
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
value === 'packing' ||
|
|
||||||
value === 'shopping' ||
|
|
||||||
value === 'todo' ||
|
|
||||||
value === 'custom'
|
|
||||||
) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException('kind is invalid.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractMessage(response: MistralChatResponse) {
|
|
||||||
const message = response.choices?.[0]?.message;
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
throw new ServiceUnavailableException('Mistral response was empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeAssistantContent(content: string | null | undefined): string {
|
|
||||||
return content?.trim() || 'Ich habe die Anfrage verarbeitet.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private systemMessage(): MistralMessage {
|
|
||||||
return {
|
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
'Du bist der Listify-Assistent. Antworte knapp und hilfreich auf Deutsch. ' +
|
|
||||||
'Nutze Tools, wenn der Nutzer Listen sehen, neue Listen erstellen oder Items hinzufuegen moechte. ' +
|
|
||||||
'Erstelle oder aendere nur, wenn der Nutzer das klar anfordert. Loeschen, Teilen, Abhaken und Bearbeiten bestehender Items ist nicht erlaubt.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private tools() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: 'list_existing_lists',
|
|
||||||
description: 'Liest die Listen des angemeldeten Users.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: 'create_list',
|
|
||||||
description:
|
|
||||||
'Erstellt eine neue Liste mit optionalen Start-Items fuer den angemeldeten User.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['name'],
|
|
||||||
properties: {
|
|
||||||
name: { type: 'string' },
|
|
||||||
description: { type: 'string' },
|
|
||||||
kind: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['packing', 'shopping', 'todo', 'custom'],
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['title'],
|
|
||||||
properties: {
|
|
||||||
title: { type: 'string' },
|
|
||||||
notes: { type: 'string' },
|
|
||||||
quantity: { type: 'number' },
|
|
||||||
required: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: 'add_list_item',
|
|
||||||
description:
|
|
||||||
'Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der User Zugriff hat.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['listId', 'title'],
|
|
||||||
properties: {
|
|
||||||
listId: { type: 'string' },
|
|
||||||
title: { type: 'string' },
|
|
||||||
notes: { type: 'string' },
|
|
||||||
quantity: { type: 'number' },
|
|
||||||
required: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user