mcp
This commit is contained in:
@@ -46,14 +46,14 @@ $ npm run start:prod
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
@@ -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`.
|
||||
- Schreibt keine Daten und legt keine Liste an.
|
||||
- Output enthaelt `suggestions` mit `name`, `description`, `kind`, `items`, optionalem Template-Bezug und `rationale`.
|
||||
- Schreibt keine Daten und legt keine Liste an.
|
||||
|
||||
- `create_list`
|
||||
- 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.
|
||||
- Output enthaelt `list` mit der erstellten Liste inklusive Items.
|
||||
- `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.
|
||||
|
||||
- `add_list_item`
|
||||
- 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`
|
||||
- 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`
|
||||
- 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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('AssistantService', () => {
|
||||
].join(' ');
|
||||
const originalFetch = global.fetch;
|
||||
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
||||
const originalModel = process.env.MISTRAL_MODEL;
|
||||
let chatLogsRepository: {
|
||||
create: jest.Mock;
|
||||
find: jest.Mock;
|
||||
@@ -26,7 +26,7 @@ describe('AssistantService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
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();
|
||||
chatLogsRepository = {
|
||||
create: jest.fn((input) => input),
|
||||
@@ -51,10 +51,10 @@ describe('AssistantService', () => {
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
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 = {
|
||||
choices: [
|
||||
{
|
||||
@@ -78,7 +78,7 @@ describe('AssistantService', () => {
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.mistral.ai/v1/agents/completions',
|
||||
'https://api.mistral.ai/v1/conversations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -86,12 +86,12 @@ describe('AssistantService', () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
agent_id: 'agent-listify',
|
||||
messages: [
|
||||
model: 'mistral-large-test',
|
||||
inputs: [
|
||||
{ role: 'assistant', content: 'Hallo' },
|
||||
{ role: 'user', content: 'Hallo' },
|
||||
{ role: 'system', content: connectorSystemMessage },
|
||||
],
|
||||
instructions: connectorSystemMessage,
|
||||
tools: [
|
||||
{
|
||||
type: 'connector',
|
||||
@@ -99,9 +99,6 @@ describe('AssistantService', () => {
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
response_format: {
|
||||
type: 'text',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -117,11 +114,16 @@ describe('AssistantService', () => {
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
provider: 'mistral',
|
||||
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
||||
agentId: 'agent-listify',
|
||||
endpoint: 'https://api.mistral.ai/v1/conversations',
|
||||
agentId: null,
|
||||
statusCode: 200,
|
||||
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' }],
|
||||
}),
|
||||
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 () => {
|
||||
listsService.listLists.mockResolvedValue([
|
||||
{
|
||||
@@ -262,15 +322,9 @@ describe('AssistantService', () => {
|
||||
});
|
||||
|
||||
const payload = getMistralRequestPayload();
|
||||
const contextMessage = payload.messages.at(-2);
|
||||
const contextContent = contextMessage?.content ?? '';
|
||||
const contextContent = payload.instructions;
|
||||
|
||||
expect(contextMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
role: 'system',
|
||||
content: expect.stringContaining('Aktueller Listify-Kontext:'),
|
||||
}),
|
||||
);
|
||||
expect(contextContent).toContain('Aktueller Listify-Kontext:');
|
||||
expect(contextContent).toContain(
|
||||
'Der User befindet sich auf einer Listendetailseite.',
|
||||
);
|
||||
@@ -287,10 +341,7 @@ describe('AssistantService', () => {
|
||||
expect(contextContent).not.toContain('ada@example.com');
|
||||
expect(contextContent).not.toContain('grace@example.com');
|
||||
expect(contextContent).not.toContain('checkedByUserId');
|
||||
expect(payload.messages.at(-1)).toEqual({
|
||||
role: 'system',
|
||||
content: connectorSystemMessage,
|
||||
});
|
||||
expect(contextContent).toContain(connectorSystemMessage);
|
||||
});
|
||||
|
||||
it('adds list items locally when the user writes onto the current list', async () => {
|
||||
@@ -390,7 +441,7 @@ describe('AssistantService', () => {
|
||||
statusCode: 502,
|
||||
responsePayload: providerResponse,
|
||||
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 () => {
|
||||
chatLogsRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 'log-1',
|
||||
userId: 'user-1',
|
||||
provider: 'mistral',
|
||||
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
||||
agentId: 'agent-listify',
|
||||
endpoint: 'https://api.mistral.ai/v1/conversations',
|
||||
agentId: null,
|
||||
statusCode: 502,
|
||||
durationMs: 123,
|
||||
requestPayload: { messages: [] },
|
||||
requestPayload: { inputs: [] },
|
||||
responsePayload: { message: 'connector failed' },
|
||||
assistantContent: null,
|
||||
errorMessage: 'Mistral agent request failed.',
|
||||
errorMessage: 'Mistral conversation request failed.',
|
||||
createdAt: new Date('2026-06-24T08:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
@@ -769,14 +800,14 @@ describe('AssistantService', () => {
|
||||
{
|
||||
id: 'log-1',
|
||||
provider: 'mistral',
|
||||
endpoint: 'https://api.mistral.ai/v1/agents/completions',
|
||||
agentId: 'agent-listify',
|
||||
endpoint: 'https://api.mistral.ai/v1/conversations',
|
||||
agentId: null,
|
||||
statusCode: 502,
|
||||
durationMs: 123,
|
||||
requestPayload: { messages: [] },
|
||||
requestPayload: { inputs: [] },
|
||||
responsePayload: { message: 'connector failed' },
|
||||
assistantContent: null,
|
||||
errorMessage: 'Mistral agent request failed.',
|
||||
errorMessage: 'Mistral conversation request failed.',
|
||||
createdAt: '2026-06-24T08:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
@@ -792,7 +823,8 @@ function mockMistralResponse(response: object, ok = true, status = 200): void {
|
||||
}
|
||||
|
||||
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 body = init?.body;
|
||||
@@ -802,6 +834,7 @@ function getMistralRequestPayload(): {
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
interface MistralAgentCompletionResponse {
|
||||
interface MistralCompletionResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
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()
|
||||
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(
|
||||
@InjectRepository(AssistantChatLogEntity)
|
||||
@@ -129,7 +148,11 @@ export class AssistantService {
|
||||
return localMutationResponse;
|
||||
}
|
||||
|
||||
const response = await this.callMistralAgent(userId, messages, context);
|
||||
const response = await this.callMistralConversation(
|
||||
userId,
|
||||
messages,
|
||||
context,
|
||||
);
|
||||
let actions = this.extractActions(response, {
|
||||
assumeCreatedListForUnknownToolResult: isCreationRequest,
|
||||
});
|
||||
@@ -470,28 +493,28 @@ export class AssistantService {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private async callMistralAgent(
|
||||
private async callMistralConversation(
|
||||
userId: string,
|
||||
messages: AssistantChatMessage[],
|
||||
context: NormalizedAssistantPageContext | null,
|
||||
): Promise<MistralAgentCompletionResponse> {
|
||||
): Promise<MistralCompletionResponse> {
|
||||
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 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 = {
|
||||
agent_id: agentId,
|
||||
messages: [
|
||||
...messages,
|
||||
...(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(' '),
|
||||
},
|
||||
],
|
||||
model,
|
||||
inputs: messages,
|
||||
instructions,
|
||||
tools: [
|
||||
{
|
||||
type: 'connector',
|
||||
@@ -499,15 +522,12 @@ export class AssistantService {
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
response_format: {
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
|
||||
if (!apiKey) {
|
||||
await this.recordChatLog({
|
||||
userId,
|
||||
agentId: agentId ?? null,
|
||||
agentId: null,
|
||||
requestPayload,
|
||||
responsePayload: 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();
|
||||
let statusCode: number | null = null;
|
||||
let responsePayload: unknown = null;
|
||||
@@ -555,22 +559,24 @@ export class AssistantService {
|
||||
if (!response.ok) {
|
||||
await this.recordChatLog({
|
||||
userId,
|
||||
agentId,
|
||||
agentId: null,
|
||||
requestPayload,
|
||||
responsePayload,
|
||||
statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
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);
|
||||
|
||||
await this.recordChatLog({
|
||||
userId,
|
||||
agentId,
|
||||
agentId: null,
|
||||
requestPayload,
|
||||
responsePayload,
|
||||
statusCode,
|
||||
@@ -579,7 +585,7 @@ export class AssistantService {
|
||||
errorMessage: null,
|
||||
});
|
||||
|
||||
return responsePayload as MistralAgentCompletionResponse;
|
||||
return responsePayload as MistralCompletionResponse;
|
||||
}
|
||||
|
||||
private async readResponsePayload(response: Response): Promise<unknown> {
|
||||
@@ -601,7 +607,7 @@ export class AssistantService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = responsePayload as MistralAgentCompletionResponse;
|
||||
const response = responsePayload as MistralCompletionResponse;
|
||||
const directContent = response.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -634,7 +659,7 @@ export class AssistantService {
|
||||
}
|
||||
|
||||
const actions: AssistantAction[] = [];
|
||||
const response = responsePayload as MistralAgentCompletionResponse;
|
||||
const response = responsePayload as MistralCompletionResponse;
|
||||
|
||||
for (const choice of response.choices ?? []) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -746,6 +808,40 @@ export class AssistantService {
|
||||
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 {
|
||||
return this.contentTextParts(value).join('\n');
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { UserListShareEntity } from './user-list-share.entity';
|
||||
describe('ListsService', () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
||||
const originalModel = process.env.MISTRAL_MODEL;
|
||||
let service: ListsService;
|
||||
let listsRepository: InMemoryRepository<UserListEntity>;
|
||||
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
|
||||
@@ -45,7 +45,7 @@ describe('ListsService', () => {
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
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 () => {
|
||||
@@ -116,8 +116,9 @@ describe('ListsService', () => {
|
||||
service.updateList('user-1', list.id, { name: 'Wieder da' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
expect(
|
||||
(await listsRepository.find()).find((storedList) => storedList.id === list.id)
|
||||
?.deletedAt,
|
||||
(await listsRepository.find()).find(
|
||||
(storedList) => storedList.id === list.id,
|
||||
)?.deletedAt,
|
||||
).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
@@ -313,12 +314,9 @@ describe('ListsService', () => {
|
||||
quantity: 2,
|
||||
required: false,
|
||||
});
|
||||
await service.updateItem(
|
||||
'user-1',
|
||||
list.id,
|
||||
withFirstItem.items[0].id,
|
||||
{ checked: true },
|
||||
);
|
||||
await service.updateItem('user-1', list.id, withFirstItem.items[0].id, {
|
||||
checked: true,
|
||||
});
|
||||
|
||||
const template = await service.createTemplateFromList(
|
||||
'user-1',
|
||||
@@ -343,7 +341,9 @@ describe('ListsService', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -416,7 +416,7 @@ describe('ListsService', () => {
|
||||
|
||||
it('suggests normalized items and filters existing titles', async () => {
|
||||
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', {
|
||||
name: 'Sommerurlaub',
|
||||
description: 'Eine Woche am Meer',
|
||||
@@ -479,7 +479,7 @@ describe('ListsService', () => {
|
||||
],
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://api.mistral.ai/v1/agents/completions',
|
||||
'https://api.mistral.ai/v1/conversations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -489,17 +489,21 @@ describe('ListsService', () => {
|
||||
}),
|
||||
);
|
||||
const requestPayload = getMistralRequestPayload();
|
||||
expect(requestPayload.messages[1].content).toContain('Name: Sommerurlaub');
|
||||
expect(requestPayload.messages[1].content).toContain(
|
||||
expect(requestPayload.model).toBe('mistral-large-test');
|
||||
expect(requestPayload.inputs[0].content).toContain('Name: Sommerurlaub');
|
||||
expect(requestPayload.inputs[0].content).toContain(
|
||||
'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();
|
||||
});
|
||||
|
||||
it('creates a list and returns item suggestions for it', async () => {
|
||||
process.env.MISTRAL_API_KEY = 'test-key';
|
||||
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
||||
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||
mockMistralResponse({
|
||||
choices: [
|
||||
{
|
||||
@@ -537,18 +541,18 @@ describe('ListsService', () => {
|
||||
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
|
||||
|
||||
const requestPayload = getMistralRequestPayload();
|
||||
expect(requestPayload.messages[1].content).toContain('Name: Sommerfest');
|
||||
expect(requestPayload.messages[1].content).toContain(
|
||||
expect(requestPayload.inputs[0].content).toContain('Name: Sommerfest');
|
||||
expect(requestPayload.inputs[0].content).toContain(
|
||||
'Beschreibung: Planung fuer Team-Event',
|
||||
);
|
||||
expect(requestPayload.messages[1].content).toContain(
|
||||
expect(requestPayload.inputs[0].content).toContain(
|
||||
'Vorhandene Items: keine',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty suggestion list for malformed provider content', async () => {
|
||||
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', {
|
||||
name: 'Einkauf',
|
||||
kind: 'shopping',
|
||||
@@ -564,7 +568,6 @@ describe('ListsService', () => {
|
||||
|
||||
it('fails clearly when item suggestions are not configured', async () => {
|
||||
delete process.env.MISTRAL_API_KEY;
|
||||
process.env.MISTRAL_AGENT_ID = 'agent-listify';
|
||||
const list = await service.createList('user-1', {
|
||||
name: 'Einkauf',
|
||||
kind: 'shopping',
|
||||
@@ -586,7 +589,9 @@ function mockMistralResponse(response: object, ok = true, status = 200): void {
|
||||
}
|
||||
|
||||
function getMistralRequestPayload(): {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
model: string;
|
||||
inputs: Array<{ role: string; content: string }>;
|
||||
instructions: string;
|
||||
tools?: unknown;
|
||||
} {
|
||||
const [, init] = jest.mocked(global.fetch).mock.calls.at(-1) ?? [];
|
||||
@@ -597,7 +602,9 @@ function getMistralRequestPayload(): {
|
||||
}
|
||||
|
||||
return JSON.parse(body) as {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
model: string;
|
||||
inputs: Array<{ role: string; content: string }>;
|
||||
instructions: string;
|
||||
tools?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface CreateListWithItemSuggestionsResponse {
|
||||
suggestions: ListItemSuggestion[];
|
||||
}
|
||||
|
||||
interface MistralAgentCompletionResponse {
|
||||
interface MistralCompletionResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string | null;
|
||||
@@ -56,12 +56,19 @@ interface MistralAgentCompletionResponse {
|
||||
content?: string | null | unknown[];
|
||||
}>;
|
||||
}>;
|
||||
outputs?: Array<{
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: string | null | unknown[];
|
||||
}>;
|
||||
output_text?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ListsService {
|
||||
private readonly itemSuggestionsEndpoint =
|
||||
'https://api.mistral.ai/v1/agents/completions';
|
||||
'https://api.mistral.ai/v1/conversations';
|
||||
private readonly defaultMistralModel = 'mistral-large-latest';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserListEntity)
|
||||
@@ -84,7 +91,10 @@ export class ListsService {
|
||||
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({
|
||||
id: randomUUID(),
|
||||
ownerId,
|
||||
@@ -122,7 +132,10 @@ export class ListsService {
|
||||
const list = await this.createList(ownerId, createDto);
|
||||
const listEntity = await this.findAccessibleList(ownerId, list.id);
|
||||
const response = await this.callMistralForItemSuggestions(listEntity);
|
||||
const suggestions = this.normalizeItemSuggestions(response, listEntity.items);
|
||||
const suggestions = this.normalizeItemSuggestions(
|
||||
response,
|
||||
listEntity.items,
|
||||
);
|
||||
|
||||
return {
|
||||
list,
|
||||
@@ -220,7 +233,10 @@ export class ListsService {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -268,7 +284,10 @@ export class ListsService {
|
||||
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 accessorIds = this.listAccessorIds(list);
|
||||
const metadata = {
|
||||
@@ -303,7 +322,9 @@ export class ListsService {
|
||||
const targetUserId = this.requireShareUserId(shareDto.userId);
|
||||
|
||||
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({
|
||||
@@ -612,7 +633,9 @@ export class ListsService {
|
||||
const list = await this.findAccessibleList(ownerId, listId);
|
||||
|
||||
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;
|
||||
@@ -730,7 +753,9 @@ export class ListsService {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -742,7 +767,9 @@ export class ListsService {
|
||||
const reminderAt = new Date(normalizedValue);
|
||||
|
||||
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;
|
||||
@@ -779,10 +806,13 @@ export class ListsService {
|
||||
].filter((userId, index, userIds) => userIds.indexOf(userId) === index);
|
||||
}
|
||||
|
||||
private async hydrateListAccessRelations(list: UserListEntity): Promise<void> {
|
||||
list.owner ??= (await this.usersRepository.findOne({
|
||||
where: { id: list.ownerId },
|
||||
})) ?? undefined;
|
||||
private async hydrateListAccessRelations(
|
||||
list: UserListEntity,
|
||||
): Promise<void> {
|
||||
list.owner ??=
|
||||
(await this.usersRepository.findOne({
|
||||
where: { id: list.ownerId },
|
||||
})) ?? undefined;
|
||||
|
||||
const storedShares = await this.listSharesRepository.find({
|
||||
where: { listId: list.id },
|
||||
@@ -791,9 +821,10 @@ export class ListsService {
|
||||
list.shares = storedShares;
|
||||
|
||||
for (const share of list.shares) {
|
||||
share.user ??= (await this.usersRepository.findOne({
|
||||
where: { id: share.userId },
|
||||
})) ?? undefined;
|
||||
share.user ??=
|
||||
(await this.usersRepository.findOne({
|
||||
where: { id: share.userId },
|
||||
})) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,7 +864,9 @@ export class ListsService {
|
||||
name: list.name,
|
||||
description: list.description ?? undefined,
|
||||
kind: list.kind,
|
||||
reminderAt: list.reminderAt ? this.toIsoString(list.reminderAt) : undefined,
|
||||
reminderAt: list.reminderAt
|
||||
? this.toIsoString(list.reminderAt)
|
||||
: undefined,
|
||||
items: (list.items ?? [])
|
||||
.sort((left, right) => left.position - right.position)
|
||||
.map((item) => this.toUserListItem(item)),
|
||||
@@ -904,14 +937,12 @@ export class ListsService {
|
||||
list: UserListEntity,
|
||||
): Promise<unknown> {
|
||||
const apiKey = process.env.MISTRAL_API_KEY;
|
||||
const agentId = process.env.MISTRAL_AGENT_ID;
|
||||
const model = process.env.MISTRAL_MODEL ?? this.defaultMistralModel;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new ServiceUnavailableException('Mistral API key is not configured.');
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
throw new ServiceUnavailableException('Mistral agent id is not configured.');
|
||||
throw new ServiceUnavailableException(
|
||||
'Mistral API key is not configured.',
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(this.itemSuggestionsEndpoint, {
|
||||
@@ -921,28 +952,24 @@ export class ListsService {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
agent_id: agentId,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Du erzeugst Listify-Item-Vorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","notes":"...","quantity":1,"required":true}]}. Keine Markdown-Ausgabe.',
|
||||
},
|
||||
model,
|
||||
inputs: [
|
||||
{
|
||||
role: 'user',
|
||||
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,
|
||||
response_format: {
|
||||
type: 'json_object',
|
||||
},
|
||||
}),
|
||||
});
|
||||
const responsePayload = await this.readMistralResponsePayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ServiceUnavailableException('Mistral agent request failed.');
|
||||
throw new ServiceUnavailableException(
|
||||
'Mistral conversation request failed.',
|
||||
);
|
||||
}
|
||||
|
||||
return responsePayload;
|
||||
@@ -983,7 +1010,9 @@ export class ListsService {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private async readMistralResponsePayload(response: Response): Promise<unknown> {
|
||||
private async readMistralResponsePayload(
|
||||
response: Response,
|
||||
): Promise<unknown> {
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (!rawBody) {
|
||||
@@ -1038,7 +1067,7 @@ export class ListsService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = responsePayload as MistralAgentCompletionResponse;
|
||||
const response = responsePayload as MistralCompletionResponse;
|
||||
const directContent = response.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1129,7 +1176,10 @@ export class ListsService {
|
||||
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();
|
||||
|
||||
if (!compacted) {
|
||||
|
||||
Reference in New Issue
Block a user