This commit is contained in:
Bastian Wagner
2026-06-24 12:56:42 +02:00
parent 712136a5e9
commit 17ac2953d6
5 changed files with 361 additions and 175 deletions

View File

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

View File

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

View File

@@ -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 requestPayload = {
agent_id: agentId,
messages: [
...messages,
...(contextMessage ? [contextMessage] : []),
{
role: 'system',
content: [
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 = {
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');
}

View File

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

View File

@@ -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,8 +806,11 @@ export class ListsService {
].filter((userId, index, userIds) => userIds.indexOf(userId) === index);
}
private async hydrateListAccessRelations(list: UserListEntity): Promise<void> {
list.owner ??= (await this.usersRepository.findOne({
private async hydrateListAccessRelations(
list: UserListEntity,
): Promise<void> {
list.owner ??=
(await this.usersRepository.findOne({
where: { id: list.ownerId },
})) ?? undefined;
@@ -791,7 +821,8 @@ export class ListsService {
list.shares = storedShares;
for (const share of list.shares) {
share.user ??= (await this.usersRepository.findOne({
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) {