mcp
This commit is contained in:
@@ -46,14 +46,14 @@ $ npm run start:prod
|
|||||||
|
|
||||||
## Mistral assistant
|
## Mistral assistant
|
||||||
|
|
||||||
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:
|
The in-app assistant calls the Mistral Conversations API from the API server. Configure the key and optional model 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_AGENT_ID=your-mistral-agent-id
|
MISTRAL_MODEL=mistral-large-latest
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
The authenticated frontend calls `POST /api/assistant/chat`; the API forwards the conversation to `POST /v1/conversations` with the `listify` connector enabled and returns the assistant text. Listify inspects returned tool outputs and refreshes local list state when a list was created or changed.
|
||||||
|
|
||||||
Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata.
|
Every Mistral response is stored in `assistant_chat_logs`. The table includes the sanitized provider request, the full raw provider response, the extracted assistant text sent back to the UI, response status and timing metadata.
|
||||||
|
|
||||||
@@ -116,8 +116,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`.
|
- Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`.
|
||||||
- Schreibt keine Daten und legt keine Liste an.
|
- Schreibt keine Daten und legt keine Liste an.
|
||||||
|
|
||||||
- `create_list`
|
- `create_list`
|
||||||
- Erstellt eine neue Liste fuer den angemeldeten User.
|
- Erstellt eine neue Liste fuer den angemeldeten User.
|
||||||
@@ -135,8 +135,8 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `items` ist optional. Wenn Items angegeben sind, werden sie nach dem Erstellen der Liste in Reihenfolge hinzugefuegt.
|
- `items` ist optional. Wenn Items angegeben sind, werden sie nach dem Erstellen der Liste in Reihenfolge hinzugefuegt.
|
||||||
- Output enthaelt `list` mit der erstellten Liste inklusive Items.
|
- Output enthaelt `list` mit der erstellten Liste inklusive Items.
|
||||||
|
|
||||||
- `add_list_item`
|
- `add_list_item`
|
||||||
- Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der angemeldete User Zugriff hat.
|
- Fuegt ein Item zu einer bestehenden Liste hinzu, auf die der angemeldete User Zugriff hat.
|
||||||
@@ -151,7 +151,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Output enthaelt `list` mit der aktualisierten Liste.
|
- Output enthaelt `list` mit der aktualisierten Liste.
|
||||||
|
|
||||||
- `create_template`
|
- `create_template`
|
||||||
- Erstellt ein neues Template fuer den angemeldeten User.
|
- Erstellt ein neues Template fuer den angemeldeten User.
|
||||||
@@ -169,7 +169,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Output enthaelt `template` mit dem erstellten Template inklusive Items.
|
- Output enthaelt `template` mit dem erstellten Template inklusive Items.
|
||||||
|
|
||||||
- `add_template_item`
|
- `add_template_item`
|
||||||
- Fuegt ein Item zu einem bestehenden Template hinzu, das dem angemeldeten User gehoert.
|
- Fuegt ein Item zu einem bestehenden Template hinzu, das dem angemeldeten User gehoert.
|
||||||
@@ -183,7 +183,7 @@ Der Server erzeugt beim MCP-Initialize eine Session. Folge-Requests muessen den
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Output enthaelt `template` mit dem aktualisierten Template.
|
- Output enthaelt `template` mit dem aktualisierten Template.
|
||||||
|
|
||||||
### Minimaler MCP-Request
|
### Minimaler MCP-Request
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe('AssistantService', () => {
|
|||||||
].join(' ');
|
].join(' ');
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
const originalApiKey = process.env.MISTRAL_API_KEY;
|
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||||
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
const originalModel = process.env.MISTRAL_MODEL;
|
||||||
let chatLogsRepository: {
|
let chatLogsRepository: {
|
||||||
create: jest.Mock;
|
create: jest.Mock;
|
||||||
find: jest.Mock;
|
find: jest.Mock;
|
||||||
@@ -26,7 +26,7 @@ describe('AssistantService', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.MISTRAL_API_KEY = 'test-key';
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||||
global.fetch = jest.fn();
|
global.fetch = jest.fn();
|
||||||
chatLogsRepository = {
|
chatLogsRepository = {
|
||||||
create: jest.fn((input) => input),
|
create: jest.fn((input) => input),
|
||||||
@@ -51,10 +51,10 @@ describe('AssistantService', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
process.env.MISTRAL_API_KEY = originalApiKey;
|
process.env.MISTRAL_API_KEY = originalApiKey;
|
||||||
process.env.MISTRAL_AGENT_ID = originalAgentId;
|
process.env.MISTRAL_MODEL = originalModel;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards messages to the configured Mistral agent', async () => {
|
it('starts a Mistral conversation with the configured model', async () => {
|
||||||
const providerResponse = {
|
const providerResponse = {
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
@@ -78,7 +78,7 @@ describe('AssistantService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
'https://api.mistral.ai/v1/agents/completions',
|
'https://api.mistral.ai/v1/conversations',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -86,12 +86,12 @@ describe('AssistantService', () => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
agent_id: 'agent-listify',
|
model: 'mistral-large-test',
|
||||||
messages: [
|
inputs: [
|
||||||
{ role: 'assistant', content: 'Hallo' },
|
{ role: 'assistant', content: 'Hallo' },
|
||||||
{ role: 'user', content: 'Hallo' },
|
{ role: 'user', content: 'Hallo' },
|
||||||
{ role: 'system', content: connectorSystemMessage },
|
|
||||||
],
|
],
|
||||||
|
instructions: connectorSystemMessage,
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
type: 'connector',
|
type: 'connector',
|
||||||
@@ -99,9 +99,6 @@ describe('AssistantService', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
stream: false,
|
stream: false,
|
||||||
response_format: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -117,11 +114,16 @@ describe('AssistantService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
provider: 'mistral',
|
provider: 'mistral',
|
||||||
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
endpoint: 'https://api.mistral.ai/v1/conversations',
|
||||||
agentId: 'agent-listify',
|
agentId: null,
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
requestPayload: expect.objectContaining({
|
requestPayload: expect.objectContaining({
|
||||||
agent_id: 'agent-listify',
|
model: 'mistral-large-test',
|
||||||
|
inputs: [
|
||||||
|
{ role: 'assistant', content: 'Hallo' },
|
||||||
|
{ role: 'user', content: 'Hallo' },
|
||||||
|
],
|
||||||
|
instructions: connectorSystemMessage,
|
||||||
tools: [{ type: 'connector', connector_id: 'listify' }],
|
tools: [{ type: 'connector', connector_id: 'listify' }],
|
||||||
}),
|
}),
|
||||||
responsePayload: providerResponse,
|
responsePayload: providerResponse,
|
||||||
@@ -131,6 +133,64 @@ describe('AssistantService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extracts assistant content and created list actions from conversation outputs', async () => {
|
||||||
|
const createdList = {
|
||||||
|
id: 'list-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
accessRole: 'owner',
|
||||||
|
name: 'Einkauf',
|
||||||
|
kind: 'shopping',
|
||||||
|
items: [],
|
||||||
|
collaborators: [],
|
||||||
|
createdAt: '2026-06-12T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const providerResponse = {
|
||||||
|
conversation_id: 'conv-1',
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
type: 'tool.execution',
|
||||||
|
tool_name: 'listify_create_list',
|
||||||
|
result: { list: createdList },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message.output',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Ich habe die Liste Einkauf angelegt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockMistralResponse(providerResponse);
|
||||||
|
|
||||||
|
const result = await service.chat('user-1', {
|
||||||
|
messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Ich habe die Liste Einkauf angelegt.',
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: 'list.created',
|
||||||
|
listId: 'list-1',
|
||||||
|
list: createdList,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
createdList,
|
||||||
|
);
|
||||||
|
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
responsePayload: providerResponse,
|
||||||
|
assistantContent: 'Ich habe die Liste Einkauf angelegt.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('answers open list read requests locally without calling Mistral', async () => {
|
it('answers open list read requests locally without calling Mistral', async () => {
|
||||||
listsService.listLists.mockResolvedValue([
|
listsService.listLists.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -262,15 +322,9 @@ describe('AssistantService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const payload = getMistralRequestPayload();
|
const payload = getMistralRequestPayload();
|
||||||
const contextMessage = payload.messages.at(-2);
|
const contextContent = payload.instructions;
|
||||||
const contextContent = contextMessage?.content ?? '';
|
|
||||||
|
|
||||||
expect(contextMessage).toEqual(
|
expect(contextContent).toContain('Aktueller Listify-Kontext:');
|
||||||
expect.objectContaining({
|
|
||||||
role: 'system',
|
|
||||||
content: expect.stringContaining('Aktueller Listify-Kontext:'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(contextContent).toContain(
|
expect(contextContent).toContain(
|
||||||
'Der User befindet sich auf einer Listendetailseite.',
|
'Der User befindet sich auf einer Listendetailseite.',
|
||||||
);
|
);
|
||||||
@@ -287,10 +341,7 @@ describe('AssistantService', () => {
|
|||||||
expect(contextContent).not.toContain('ada@example.com');
|
expect(contextContent).not.toContain('ada@example.com');
|
||||||
expect(contextContent).not.toContain('grace@example.com');
|
expect(contextContent).not.toContain('grace@example.com');
|
||||||
expect(contextContent).not.toContain('checkedByUserId');
|
expect(contextContent).not.toContain('checkedByUserId');
|
||||||
expect(payload.messages.at(-1)).toEqual({
|
expect(contextContent).toContain(connectorSystemMessage);
|
||||||
role: 'system',
|
|
||||||
content: connectorSystemMessage,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds list items locally when the user writes onto the current list', async () => {
|
it('adds list items locally when the user writes onto the current list', async () => {
|
||||||
@@ -390,7 +441,7 @@ describe('AssistantService', () => {
|
|||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
responsePayload: providerResponse,
|
responsePayload: providerResponse,
|
||||||
assistantContent: null,
|
assistantContent: null,
|
||||||
errorMessage: 'Mistral agent request failed.',
|
errorMessage: 'Mistral conversation request failed.',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -720,40 +771,20 @@ describe('AssistantService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
expect(chatLogsRepository.save).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
userId: 'user-1',
|
|
||||||
agentId: null,
|
|
||||||
statusCode: null,
|
|
||||||
responsePayload: null,
|
|
||||||
assistantContent: null,
|
|
||||||
errorMessage: 'Mistral agent id is not configured.',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists the latest chat logs for the current user', async () => {
|
it('lists the latest chat logs for the current user', async () => {
|
||||||
chatLogsRepository.find.mockResolvedValue([
|
chatLogsRepository.find.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'log-1',
|
id: 'log-1',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
provider: 'mistral',
|
provider: 'mistral',
|
||||||
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
endpoint: 'https://api.mistral.ai/v1/conversations',
|
||||||
agentId: 'agent-listify',
|
agentId: null,
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
durationMs: 123,
|
durationMs: 123,
|
||||||
requestPayload: { messages: [] },
|
requestPayload: { inputs: [] },
|
||||||
responsePayload: { message: 'connector failed' },
|
responsePayload: { message: 'connector failed' },
|
||||||
assistantContent: null,
|
assistantContent: null,
|
||||||
errorMessage: 'Mistral agent request failed.',
|
errorMessage: 'Mistral conversation request failed.',
|
||||||
createdAt: new Date('2026-06-24T08:00:00.000Z'),
|
createdAt: new Date('2026-06-24T08:00:00.000Z'),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -769,14 +800,14 @@ describe('AssistantService', () => {
|
|||||||
{
|
{
|
||||||
id: 'log-1',
|
id: 'log-1',
|
||||||
provider: 'mistral',
|
provider: 'mistral',
|
||||||
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
endpoint: 'https://api.mistral.ai/v1/conversations',
|
||||||
agentId: 'agent-listify',
|
agentId: null,
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
durationMs: 123,
|
durationMs: 123,
|
||||||
requestPayload: { messages: [] },
|
requestPayload: { inputs: [] },
|
||||||
responsePayload: { message: 'connector failed' },
|
responsePayload: { message: 'connector failed' },
|
||||||
assistantContent: null,
|
assistantContent: null,
|
||||||
errorMessage: 'Mistral agent request failed.',
|
errorMessage: 'Mistral conversation request failed.',
|
||||||
createdAt: '2026-06-24T08:00:00.000Z',
|
createdAt: '2026-06-24T08:00:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -792,7 +823,8 @@ function mockMistralResponse(response: object, ok = true, status = 200): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMistralRequestPayload(): {
|
function getMistralRequestPayload(): {
|
||||||
messages: Array<{ role: string; content: string }>;
|
inputs: Array<{ role: string; content: string }>;
|
||||||
|
instructions: string;
|
||||||
} {
|
} {
|
||||||
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
|
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
|
||||||
const body = init?.body;
|
const body = init?.body;
|
||||||
@@ -802,6 +834,7 @@ function getMistralRequestPayload(): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return JSON.parse(body) as {
|
return JSON.parse(body) as {
|
||||||
messages: Array<{ role: string; content: string }>;
|
inputs: Array<{ role: string; content: string }>;
|
||||||
|
instructions: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ type NormalizedAssistantPageContext =
|
|||||||
}
|
}
|
||||||
| { page: 'unknown'; route: string };
|
| { page: 'unknown'; route: string };
|
||||||
|
|
||||||
interface MistralAgentCompletionResponse {
|
interface MistralCompletionResponse {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
@@ -89,9 +89,28 @@ interface MistralAgentCompletionResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MistralConversationResponse {
|
||||||
|
outputs?: Array<{
|
||||||
|
type?: string;
|
||||||
|
role?: string;
|
||||||
|
content?: string | null | unknown[];
|
||||||
|
result?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
tool_name?: string;
|
||||||
|
name?: string;
|
||||||
|
metadata?: {
|
||||||
|
mcp_meta?: {
|
||||||
|
structuredContent?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
output_text?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssistantService {
|
export class AssistantService {
|
||||||
private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions';
|
private readonly endpoint = 'https://api.mistral.ai/v1/conversations';
|
||||||
|
private readonly defaultModel = 'mistral-large-latest';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssistantChatLogEntity)
|
@InjectRepository(AssistantChatLogEntity)
|
||||||
@@ -129,7 +148,11 @@ export class AssistantService {
|
|||||||
return localMutationResponse;
|
return localMutationResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.callMistralAgent(userId, messages, context);
|
const response = await this.callMistralConversation(
|
||||||
|
userId,
|
||||||
|
messages,
|
||||||
|
context,
|
||||||
|
);
|
||||||
let actions = this.extractActions(response, {
|
let actions = this.extractActions(response, {
|
||||||
assumeCreatedListForUnknownToolResult: isCreationRequest,
|
assumeCreatedListForUnknownToolResult: isCreationRequest,
|
||||||
});
|
});
|
||||||
@@ -470,28 +493,28 @@ export class AssistantService {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async callMistralAgent(
|
private async callMistralConversation(
|
||||||
userId: string,
|
userId: string,
|
||||||
messages: AssistantChatMessage[],
|
messages: AssistantChatMessage[],
|
||||||
context: NormalizedAssistantPageContext | null,
|
context: NormalizedAssistantPageContext | null,
|
||||||
): Promise<MistralAgentCompletionResponse> {
|
): Promise<MistralCompletionResponse> {
|
||||||
const apiKey = process.env.MISTRAL_API_KEY;
|
const apiKey = process.env.MISTRAL_API_KEY;
|
||||||
const agentId = process.env.MISTRAL_AGENT_ID;
|
const model = process.env.MISTRAL_MODEL ?? this.defaultModel;
|
||||||
const contextMessage = this.createContextSystemMessage(context);
|
const contextMessage = this.createContextSystemMessage(context);
|
||||||
|
const instructions = [
|
||||||
|
contextMessage?.content,
|
||||||
|
[
|
||||||
|
'Benutze fuer Listify-Daten immer den listify Connector.',
|
||||||
|
'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.',
|
||||||
|
'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.',
|
||||||
|
].join(' '),
|
||||||
|
]
|
||||||
|
.filter((content): content is string => Boolean(content))
|
||||||
|
.join('\n\n');
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
agent_id: agentId,
|
model,
|
||||||
messages: [
|
inputs: messages,
|
||||||
...messages,
|
instructions,
|
||||||
...(contextMessage ? [contextMessage] : []),
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: [
|
|
||||||
'Benutze fuer Listify-Daten immer den listify Connector.',
|
|
||||||
'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.',
|
|
||||||
'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.',
|
|
||||||
].join(' '),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
type: 'connector',
|
type: 'connector',
|
||||||
@@ -499,15 +522,12 @@ export class AssistantService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
stream: false,
|
stream: false,
|
||||||
response_format: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
await this.recordChatLog({
|
await this.recordChatLog({
|
||||||
userId,
|
userId,
|
||||||
agentId: agentId ?? null,
|
agentId: null,
|
||||||
requestPayload,
|
requestPayload,
|
||||||
responsePayload: null,
|
responsePayload: null,
|
||||||
statusCode: null,
|
statusCode: null,
|
||||||
@@ -520,22 +540,6 @@ export class AssistantService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!agentId) {
|
|
||||||
await this.recordChatLog({
|
|
||||||
userId,
|
|
||||||
agentId: null,
|
|
||||||
requestPayload,
|
|
||||||
responsePayload: null,
|
|
||||||
statusCode: null,
|
|
||||||
durationMs: 0,
|
|
||||||
assistantContent: null,
|
|
||||||
errorMessage: 'Mistral agent id is not configured.',
|
|
||||||
});
|
|
||||||
throw new ServiceUnavailableException(
|
|
||||||
'Mistral agent id is not configured.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let statusCode: number | null = null;
|
let statusCode: number | null = null;
|
||||||
let responsePayload: unknown = null;
|
let responsePayload: unknown = null;
|
||||||
@@ -555,22 +559,24 @@ export class AssistantService {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await this.recordChatLog({
|
await this.recordChatLog({
|
||||||
userId,
|
userId,
|
||||||
agentId,
|
agentId: null,
|
||||||
requestPayload,
|
requestPayload,
|
||||||
responsePayload,
|
responsePayload,
|
||||||
statusCode,
|
statusCode,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
assistantContent,
|
assistantContent,
|
||||||
errorMessage: 'Mistral agent request failed.',
|
errorMessage: 'Mistral conversation request failed.',
|
||||||
});
|
});
|
||||||
throw new ServiceUnavailableException('Mistral agent request failed.');
|
throw new ServiceUnavailableException(
|
||||||
|
'Mistral conversation request failed.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assistantContent = this.extractAssistantContent(responsePayload);
|
assistantContent = this.extractAssistantContent(responsePayload);
|
||||||
|
|
||||||
await this.recordChatLog({
|
await this.recordChatLog({
|
||||||
userId,
|
userId,
|
||||||
agentId,
|
agentId: null,
|
||||||
requestPayload,
|
requestPayload,
|
||||||
responsePayload,
|
responsePayload,
|
||||||
statusCode,
|
statusCode,
|
||||||
@@ -579,7 +585,7 @@ export class AssistantService {
|
|||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return responsePayload as MistralAgentCompletionResponse;
|
return responsePayload as MistralCompletionResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readResponsePayload(response: Response): Promise<unknown> {
|
private async readResponsePayload(response: Response): Promise<unknown> {
|
||||||
@@ -601,7 +607,7 @@ export class AssistantService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = responsePayload as MistralAgentCompletionResponse;
|
const response = responsePayload as MistralCompletionResponse;
|
||||||
const directContent = response.choices?.[0]?.message?.content?.trim();
|
const directContent = response.choices?.[0]?.message?.content?.trim();
|
||||||
|
|
||||||
if (directContent && !this.looksLikeJson(directContent)) {
|
if (directContent && !this.looksLikeJson(directContent)) {
|
||||||
@@ -622,6 +628,25 @@ export class AssistantService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conversation = responsePayload as MistralConversationResponse;
|
||||||
|
const outputText = conversation.output_text?.trim();
|
||||||
|
|
||||||
|
if (outputText && !this.looksLikeJson(outputText)) {
|
||||||
|
return outputText;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of [...(conversation.outputs ?? [])].reverse()) {
|
||||||
|
const content = this.contentToText(output.content).trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
content &&
|
||||||
|
!this.looksLikeJson(content) &&
|
||||||
|
(output.role === 'assistant' || output.type?.includes('message'))
|
||||||
|
) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +659,7 @@ export class AssistantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions: AssistantAction[] = [];
|
const actions: AssistantAction[] = [];
|
||||||
const response = responsePayload as MistralAgentCompletionResponse;
|
const response = responsePayload as MistralCompletionResponse;
|
||||||
|
|
||||||
for (const choice of response.choices ?? []) {
|
for (const choice of response.choices ?? []) {
|
||||||
const toolNamesById = new Map<string, string>();
|
const toolNamesById = new Map<string, string>();
|
||||||
@@ -701,6 +726,43 @@ export class AssistantService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conversation = responsePayload as MistralConversationResponse;
|
||||||
|
|
||||||
|
for (const output of conversation.outputs ?? []) {
|
||||||
|
const structuredContent = output.metadata?.mcp_meta?.structuredContent;
|
||||||
|
const list =
|
||||||
|
this.listFromStructuredContent(structuredContent) ??
|
||||||
|
this.listFromStructuredContent(output.result) ??
|
||||||
|
this.listFromStructuredContent(output.output) ??
|
||||||
|
this.listFromToolContent(output.content) ??
|
||||||
|
this.listFromNestedContent(output);
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = output.tool_name ?? output.name;
|
||||||
|
|
||||||
|
if (
|
||||||
|
toolName?.includes('create_list') ||
|
||||||
|
options.assumeCreatedListForUnknownToolResult
|
||||||
|
) {
|
||||||
|
actions.push({
|
||||||
|
type: 'list.created',
|
||||||
|
listId: list.id,
|
||||||
|
list,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
type: 'list.item_added',
|
||||||
|
listId: list.id,
|
||||||
|
itemTitle: this.lastItemTitle(list),
|
||||||
|
list,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return this.uniqueActions(actions);
|
return this.uniqueActions(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +808,40 @@ export class AssistantService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private listFromNestedContent(value: unknown): UserList | null {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directList = this.listFromStructuredContent(value);
|
||||||
|
|
||||||
|
if (directList) {
|
||||||
|
return directList;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const nestedList = this.listFromNestedContent(item);
|
||||||
|
|
||||||
|
if (nestedList) {
|
||||||
|
return nestedList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nestedValue of Object.values(value as Record<string, unknown>)) {
|
||||||
|
const nestedList = this.listFromNestedContent(nestedValue);
|
||||||
|
|
||||||
|
if (nestedList) {
|
||||||
|
return nestedList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private contentToText(value: unknown): string {
|
private contentToText(value: unknown): string {
|
||||||
return this.contentTextParts(value).join('\n');
|
return this.contentTextParts(value).join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { UserListShareEntity } from './user-list-share.entity';
|
|||||||
describe('ListsService', () => {
|
describe('ListsService', () => {
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
const originalApiKey = process.env.MISTRAL_API_KEY;
|
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||||
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
const originalModel = process.env.MISTRAL_MODEL;
|
||||||
let service: ListsService;
|
let service: ListsService;
|
||||||
let listsRepository: InMemoryRepository<UserListEntity>;
|
let listsRepository: InMemoryRepository<UserListEntity>;
|
||||||
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
|
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
|
||||||
@@ -45,7 +45,7 @@ describe('ListsService', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
restoreEnv('MISTRAL_API_KEY', originalApiKey);
|
restoreEnv('MISTRAL_API_KEY', originalApiKey);
|
||||||
restoreEnv('MISTRAL_AGENT_ID', originalAgentId);
|
restoreEnv('MISTRAL_MODEL', originalModel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates and lists concrete lists for the owning user', async () => {
|
it('creates and lists concrete lists for the owning user', async () => {
|
||||||
@@ -116,8 +116,9 @@ describe('ListsService', () => {
|
|||||||
service.updateList('user-1', list.id, { name: 'Wieder da' }),
|
service.updateList('user-1', list.id, { name: 'Wieder da' }),
|
||||||
).rejects.toThrow(NotFoundException);
|
).rejects.toThrow(NotFoundException);
|
||||||
expect(
|
expect(
|
||||||
(await listsRepository.find()).find((storedList) => storedList.id === list.id)
|
(await listsRepository.find()).find(
|
||||||
?.deletedAt,
|
(storedList) => storedList.id === list.id,
|
||||||
|
)?.deletedAt,
|
||||||
).toBeInstanceOf(Date);
|
).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,12 +314,9 @@ describe('ListsService', () => {
|
|||||||
quantity: 2,
|
quantity: 2,
|
||||||
required: false,
|
required: false,
|
||||||
});
|
});
|
||||||
await service.updateItem(
|
await service.updateItem('user-1', list.id, withFirstItem.items[0].id, {
|
||||||
'user-1',
|
checked: true,
|
||||||
list.id,
|
});
|
||||||
withFirstItem.items[0].id,
|
|
||||||
{ checked: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const template = await service.createTemplateFromList(
|
const template = await service.createTemplateFromList(
|
||||||
'user-1',
|
'user-1',
|
||||||
@@ -343,7 +341,9 @@ describe('ListsService', () => {
|
|||||||
position: 1,
|
position: 1,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect((template.items[0] as { checked?: unknown }).checked).toBeUndefined();
|
expect(
|
||||||
|
(template.items[0] as { checked?: unknown }).checked,
|
||||||
|
).toBeUndefined();
|
||||||
expect(await templatesRepository.find()).toHaveLength(1);
|
expect(await templatesRepository.find()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ describe('ListsService', () => {
|
|||||||
|
|
||||||
it('suggests normalized items and filters existing titles', async () => {
|
it('suggests normalized items and filters existing titles', async () => {
|
||||||
process.env.MISTRAL_API_KEY = 'test-key';
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||||
const list = await service.createList('user-1', {
|
const list = await service.createList('user-1', {
|
||||||
name: 'Sommerurlaub',
|
name: 'Sommerurlaub',
|
||||||
description: 'Eine Woche am Meer',
|
description: 'Eine Woche am Meer',
|
||||||
@@ -479,7 +479,7 @@ describe('ListsService', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
'https://api.mistral.ai/v1/agents/completions',
|
'https://api.mistral.ai/v1/conversations',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -489,17 +489,21 @@ describe('ListsService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const requestPayload = getMistralRequestPayload();
|
const requestPayload = getMistralRequestPayload();
|
||||||
expect(requestPayload.messages[1].content).toContain('Name: Sommerurlaub');
|
expect(requestPayload.model).toBe('mistral-large-test');
|
||||||
expect(requestPayload.messages[1].content).toContain(
|
expect(requestPayload.inputs[0].content).toContain('Name: Sommerurlaub');
|
||||||
|
expect(requestPayload.inputs[0].content).toContain(
|
||||||
'Beschreibung: Eine Woche am Meer',
|
'Beschreibung: Eine Woche am Meer',
|
||||||
);
|
);
|
||||||
expect(requestPayload.messages[1].content).toContain('Titel: Pass');
|
expect(requestPayload.inputs[0].content).toContain('Titel: Pass');
|
||||||
|
expect(requestPayload.instructions).toContain(
|
||||||
|
'Du erzeugst Listify-Item-Vorschlaege.',
|
||||||
|
);
|
||||||
expect(requestPayload.tools).toBeUndefined();
|
expect(requestPayload.tools).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a list and returns item suggestions for it', async () => {
|
it('creates a list and returns item suggestions for it', async () => {
|
||||||
process.env.MISTRAL_API_KEY = 'test-key';
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||||
mockMistralResponse({
|
mockMistralResponse({
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
@@ -537,18 +541,18 @@ describe('ListsService', () => {
|
|||||||
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
|
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
|
||||||
|
|
||||||
const requestPayload = getMistralRequestPayload();
|
const requestPayload = getMistralRequestPayload();
|
||||||
expect(requestPayload.messages[1].content).toContain('Name: Sommerfest');
|
expect(requestPayload.inputs[0].content).toContain('Name: Sommerfest');
|
||||||
expect(requestPayload.messages[1].content).toContain(
|
expect(requestPayload.inputs[0].content).toContain(
|
||||||
'Beschreibung: Planung fuer Team-Event',
|
'Beschreibung: Planung fuer Team-Event',
|
||||||
);
|
);
|
||||||
expect(requestPayload.messages[1].content).toContain(
|
expect(requestPayload.inputs[0].content).toContain(
|
||||||
'Vorhandene Items: keine',
|
'Vorhandene Items: keine',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty suggestion list for malformed provider content', async () => {
|
it('returns an empty suggestion list for malformed provider content', async () => {
|
||||||
process.env.MISTRAL_API_KEY = 'test-key';
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||||
const list = await service.createList('user-1', {
|
const list = await service.createList('user-1', {
|
||||||
name: 'Einkauf',
|
name: 'Einkauf',
|
||||||
kind: 'shopping',
|
kind: 'shopping',
|
||||||
@@ -564,7 +568,6 @@ describe('ListsService', () => {
|
|||||||
|
|
||||||
it('fails clearly when item suggestions are not configured', async () => {
|
it('fails clearly when item suggestions are not configured', async () => {
|
||||||
delete process.env.MISTRAL_API_KEY;
|
delete process.env.MISTRAL_API_KEY;
|
||||||
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
|
||||||
const list = await service.createList('user-1', {
|
const list = await service.createList('user-1', {
|
||||||
name: 'Einkauf',
|
name: 'Einkauf',
|
||||||
kind: 'shopping',
|
kind: 'shopping',
|
||||||
@@ -586,7 +589,9 @@ function mockMistralResponse(response: object, ok = true, status = 200): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMistralRequestPayload(): {
|
function getMistralRequestPayload(): {
|
||||||
messages: Array<{ role: string; content: string }>;
|
model: string;
|
||||||
|
inputs: Array<{ role: string; content: string }>;
|
||||||
|
instructions: string;
|
||||||
tools?: unknown;
|
tools?: unknown;
|
||||||
} {
|
} {
|
||||||
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
|
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
|
||||||
@@ -597,7 +602,9 @@ function getMistralRequestPayload(): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return JSON.parse(body) as {
|
return JSON.parse(body) as {
|
||||||
messages: Array<{ role: string; content: string }>;
|
model: string;
|
||||||
|
inputs: Array<{ role: string; content: string }>;
|
||||||
|
instructions: string;
|
||||||
tools?: unknown;
|
tools?: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface CreateListWithItemSuggestionsResponse {
|
|||||||
suggestions: ListItemSuggestion[];
|
suggestions: ListItemSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MistralAgentCompletionResponse {
|
interface MistralCompletionResponse {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
@@ -56,12 +56,19 @@ interface MistralAgentCompletionResponse {
|
|||||||
content?: string | null | unknown[];
|
content?: string | null | unknown[];
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
|
outputs?: Array<{
|
||||||
|
type?: string;
|
||||||
|
role?: string;
|
||||||
|
content?: string | null | unknown[];
|
||||||
|
}>;
|
||||||
|
output_text?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ListsService {
|
export class ListsService {
|
||||||
private readonly itemSuggestionsEndpoint =
|
private readonly itemSuggestionsEndpoint =
|
||||||
'https://api.mistral.ai/v1/agents/completions';
|
'https://api.mistral.ai/v1/conversations';
|
||||||
|
private readonly defaultMistralModel = 'mistral-large-latest';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserListEntity)
|
@InjectRepository(UserListEntity)
|
||||||
@@ -84,7 +91,10 @@ export class ListsService {
|
|||||||
private readonly templateItemsRepository?: Repository<ListTemplateItemEntity>,
|
private readonly templateItemsRepository?: Repository<ListTemplateItemEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
|
async createList(
|
||||||
|
ownerId: string,
|
||||||
|
createDto: CreateListDto,
|
||||||
|
): Promise<UserList> {
|
||||||
const list = this.listsRepository.create({
|
const list = this.listsRepository.create({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
ownerId,
|
ownerId,
|
||||||
@@ -122,7 +132,10 @@ export class ListsService {
|
|||||||
const list = await this.createList(ownerId, createDto);
|
const list = await this.createList(ownerId, createDto);
|
||||||
const listEntity = await this.findAccessibleList(ownerId, list.id);
|
const listEntity = await this.findAccessibleList(ownerId, list.id);
|
||||||
const response = await this.callMistralForItemSuggestions(listEntity);
|
const response = await this.callMistralForItemSuggestions(listEntity);
|
||||||
const suggestions = this.normalizeItemSuggestions(response, listEntity.items);
|
const suggestions = this.normalizeItemSuggestions(
|
||||||
|
response,
|
||||||
|
listEntity.items,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
@@ -220,7 +233,10 @@ export class ListsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getList(ownerId: string, listId: string): Promise<UserList> {
|
async getList(ownerId: string, listId: string): Promise<UserList> {
|
||||||
return this.toUserList(await this.findAccessibleList(ownerId, listId), ownerId);
|
return this.toUserList(
|
||||||
|
await this.findAccessibleList(ownerId, listId),
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateList(
|
async updateList(
|
||||||
@@ -268,7 +284,10 @@ export class ListsService {
|
|||||||
return userList;
|
return userList;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> {
|
async deleteList(
|
||||||
|
ownerId: string,
|
||||||
|
listId: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
const list = await this.findOwnedList(ownerId, listId);
|
const list = await this.findOwnedList(ownerId, listId);
|
||||||
const accessorIds = this.listAccessorIds(list);
|
const accessorIds = this.listAccessorIds(list);
|
||||||
const metadata = {
|
const metadata = {
|
||||||
@@ -303,7 +322,9 @@ export class ListsService {
|
|||||||
const targetUserId = this.requireShareUserId(shareDto.userId);
|
const targetUserId = this.requireShareUserId(shareDto.userId);
|
||||||
|
|
||||||
if (targetUserId === ownerId) {
|
if (targetUserId === ownerId) {
|
||||||
throw new BadRequestException('List owner cannot be added as collaborator.');
|
throw new BadRequestException(
|
||||||
|
'List owner cannot be added as collaborator.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUser = await this.usersRepository.findOne({
|
const targetUser = await this.usersRepository.findOne({
|
||||||
@@ -612,7 +633,9 @@ export class ListsService {
|
|||||||
const list = await this.findAccessibleList(ownerId, listId);
|
const list = await this.findAccessibleList(ownerId, listId);
|
||||||
|
|
||||||
if (list.ownerId !== ownerId) {
|
if (list.ownerId !== ownerId) {
|
||||||
throw new ForbiddenException('Only the list owner can perform this action.');
|
throw new ForbiddenException(
|
||||||
|
'Only the list owner can perform this action.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
@@ -730,7 +753,9 @@ export class ListsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new BadRequestException('Reminder time must be an ISO date string.');
|
throw new BadRequestException(
|
||||||
|
'Reminder time must be an ISO date string.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedValue = value.trim();
|
const normalizedValue = value.trim();
|
||||||
@@ -742,7 +767,9 @@ export class ListsService {
|
|||||||
const reminderAt = new Date(normalizedValue);
|
const reminderAt = new Date(normalizedValue);
|
||||||
|
|
||||||
if (Number.isNaN(reminderAt.getTime())) {
|
if (Number.isNaN(reminderAt.getTime())) {
|
||||||
throw new BadRequestException('Reminder time must be an ISO date string.');
|
throw new BadRequestException(
|
||||||
|
'Reminder time must be an ISO date string.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return reminderAt;
|
return reminderAt;
|
||||||
@@ -779,10 +806,13 @@ export class ListsService {
|
|||||||
].filter((userId, index, userIds) => userIds.indexOf(userId) === index);
|
].filter((userId, index, userIds) => userIds.indexOf(userId) === index);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hydrateListAccessRelations(list: UserListEntity): Promise<void> {
|
private async hydrateListAccessRelations(
|
||||||
list.owner ??= (await this.usersRepository.findOne({
|
list: UserListEntity,
|
||||||
where: { id: list.ownerId },
|
): Promise<void> {
|
||||||
})) ?? undefined;
|
list.owner ??=
|
||||||
|
(await this.usersRepository.findOne({
|
||||||
|
where: { id: list.ownerId },
|
||||||
|
})) ?? undefined;
|
||||||
|
|
||||||
const storedShares = await this.listSharesRepository.find({
|
const storedShares = await this.listSharesRepository.find({
|
||||||
where: { listId: list.id },
|
where: { listId: list.id },
|
||||||
@@ -791,9 +821,10 @@ export class ListsService {
|
|||||||
list.shares = storedShares;
|
list.shares = storedShares;
|
||||||
|
|
||||||
for (const share of list.shares) {
|
for (const share of list.shares) {
|
||||||
share.user ??= (await this.usersRepository.findOne({
|
share.user ??=
|
||||||
where: { id: share.userId },
|
(await this.usersRepository.findOne({
|
||||||
})) ?? undefined;
|
where: { id: share.userId },
|
||||||
|
})) ?? undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,7 +864,9 @@ export class ListsService {
|
|||||||
name: list.name,
|
name: list.name,
|
||||||
description: list.description ?? undefined,
|
description: list.description ?? undefined,
|
||||||
kind: list.kind,
|
kind: list.kind,
|
||||||
reminderAt: list.reminderAt ? this.toIsoString(list.reminderAt) : undefined,
|
reminderAt: list.reminderAt
|
||||||
|
? this.toIsoString(list.reminderAt)
|
||||||
|
: undefined,
|
||||||
items: (list.items ?? [])
|
items: (list.items ?? [])
|
||||||
.sort((left, right) => left.position - right.position)
|
.sort((left, right) => left.position - right.position)
|
||||||
.map((item) => this.toUserListItem(item)),
|
.map((item) => this.toUserListItem(item)),
|
||||||
@@ -904,14 +937,12 @@ export class ListsService {
|
|||||||
list: UserListEntity,
|
list: UserListEntity,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const apiKey = process.env.MISTRAL_API_KEY;
|
const apiKey = process.env.MISTRAL_API_KEY;
|
||||||
const agentId = process.env.MISTRAL_AGENT_ID;
|
const model = process.env.MISTRAL_MODEL ?? this.defaultMistralModel;
|
||||||
|
|
||||||
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.itemSuggestionsEndpoint, {
|
const response = await fetch(this.itemSuggestionsEndpoint, {
|
||||||
@@ -921,28 +952,24 @@ export class ListsService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
agent_id: agentId,
|
model,
|
||||||
messages: [
|
inputs: [
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: this.createItemSuggestionPrompt(list),
|
content: this.createItemSuggestionPrompt(list),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
instructions:
|
||||||
|
'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.',
|
||||||
stream: false,
|
stream: false,
|
||||||
response_format: {
|
|
||||||
type: 'json_object',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const responsePayload = await this.readMistralResponsePayload(response);
|
const responsePayload = await this.readMistralResponsePayload(response);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ServiceUnavailableException('Mistral agent request failed.');
|
throw new ServiceUnavailableException(
|
||||||
|
'Mistral conversation request failed.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return responsePayload;
|
return responsePayload;
|
||||||
@@ -983,7 +1010,9 @@ export class ListsService {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readMistralResponsePayload(response: Response): Promise<unknown> {
|
private async readMistralResponsePayload(
|
||||||
|
response: Response,
|
||||||
|
): Promise<unknown> {
|
||||||
const rawBody = await response.text();
|
const rawBody = await response.text();
|
||||||
|
|
||||||
if (!rawBody) {
|
if (!rawBody) {
|
||||||
@@ -1038,7 +1067,7 @@ export class ListsService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = responsePayload as MistralAgentCompletionResponse;
|
const response = responsePayload as MistralCompletionResponse;
|
||||||
const directContent = response.choices?.[0]?.message?.content?.trim();
|
const directContent = response.choices?.[0]?.message?.content?.trim();
|
||||||
|
|
||||||
if (directContent) {
|
if (directContent) {
|
||||||
@@ -1060,6 +1089,24 @@ export class ListsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const outputText = response.output_text?.trim();
|
||||||
|
|
||||||
|
if (outputText) {
|
||||||
|
return outputText;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of [...(response.outputs ?? [])].reverse()) {
|
||||||
|
const content =
|
||||||
|
typeof output.content === 'string' ? output.content.trim() : '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
content &&
|
||||||
|
(output.role === 'assistant' || output.type?.includes('message'))
|
||||||
|
) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,7 +1176,10 @@ export class ListsService {
|
|||||||
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private compactText(value: string | undefined, maxLength: number): string | undefined {
|
private compactText(
|
||||||
|
value: string | undefined,
|
||||||
|
maxLength: number,
|
||||||
|
): string | undefined {
|
||||||
const compacted = value?.replace(/\s+/g, ' ').trim();
|
const compacted = value?.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
if (!compacted) {
|
if (!compacted) {
|
||||||
|
|||||||
Reference in New Issue
Block a user