1068 lines
29 KiB
TypeScript
1068 lines
29 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { randomUUID } from 'crypto';
|
|
import { Repository } from 'typeorm';
|
|
import {
|
|
ListTemplate,
|
|
UserList,
|
|
UserListItem,
|
|
} from '../list-templates/list-template.types';
|
|
import { ListRealtimeService } from '../lists/list-realtime.service';
|
|
import { ListsService } from '../lists/lists.service';
|
|
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
|
import {
|
|
AssistantAction,
|
|
AssistantChatMessage,
|
|
AssistantChatLog,
|
|
AssistantPageContext,
|
|
AssistantChatRequest,
|
|
AssistantChatResponse,
|
|
} from './assistant.types';
|
|
|
|
type MistralMessage =
|
|
| AssistantChatMessage
|
|
| { role: 'system'; content: string };
|
|
|
|
interface NormalizedContextItem {
|
|
id: string;
|
|
title: string;
|
|
notes?: string;
|
|
quantity?: number;
|
|
required?: boolean;
|
|
checked?: boolean;
|
|
}
|
|
|
|
interface NormalizedContextList {
|
|
id: string;
|
|
name: string;
|
|
kind: string;
|
|
description?: string;
|
|
items: NormalizedContextItem[];
|
|
omittedItems: number;
|
|
}
|
|
|
|
interface NormalizedContextTemplate {
|
|
id: string;
|
|
name: string;
|
|
kind: string;
|
|
description?: string;
|
|
items: NormalizedContextItem[];
|
|
omittedItems: number;
|
|
}
|
|
|
|
type NormalizedAssistantPageContext =
|
|
| { page: 'lists_overview'; route: string }
|
|
| { page: 'list_detail'; route: string; list: NormalizedContextList }
|
|
| { page: 'templates_overview'; route: string }
|
|
| {
|
|
page: 'template_detail';
|
|
route: string;
|
|
template: NormalizedContextTemplate;
|
|
}
|
|
| { page: 'unknown'; route: string };
|
|
|
|
interface MistralAgentCompletionResponse {
|
|
choices?: Array<{
|
|
message?: {
|
|
content?: string | null;
|
|
};
|
|
messages?: Array<{
|
|
role?: string;
|
|
content?: string | null | unknown[];
|
|
tool_call_id?: string;
|
|
tool_calls?: Array<{
|
|
id?: string;
|
|
function?: {
|
|
name?: string;
|
|
};
|
|
}>;
|
|
metadata?: {
|
|
mcp_meta?: {
|
|
structuredContent?: unknown;
|
|
};
|
|
};
|
|
}>;
|
|
}>;
|
|
}
|
|
|
|
@Injectable()
|
|
export class AssistantService {
|
|
private readonly endpoint = 'https://api.mistral.ai/v1/agents/completions';
|
|
|
|
constructor(
|
|
@InjectRepository(AssistantChatLogEntity)
|
|
private readonly chatLogsRepository: Repository<AssistantChatLogEntity>,
|
|
private readonly listRealtimeService: ListRealtimeService,
|
|
private readonly listsService: ListsService,
|
|
) {}
|
|
|
|
async chat(
|
|
userId: string,
|
|
request: AssistantChatRequest,
|
|
): Promise<AssistantChatResponse> {
|
|
const messages = this.normalizeMessages(request.messages);
|
|
const context = this.normalizeContext(request.context);
|
|
const localResponse = await this.tryHandleLocalListQuery(userId, messages);
|
|
|
|
if (localResponse) {
|
|
return localResponse;
|
|
}
|
|
|
|
const localMutationResponse = await this.tryHandleLocalListMutation(
|
|
userId,
|
|
messages,
|
|
context,
|
|
);
|
|
|
|
if (localMutationResponse) {
|
|
return localMutationResponse;
|
|
}
|
|
|
|
const response = await this.callMistralAgent(userId, messages, context);
|
|
const actions = this.extractActions(response);
|
|
const content =
|
|
this.extractAssistantContent(response) ??
|
|
this.createActionContent(actions);
|
|
const latestUserMessage = this.latestUserMessage(messages);
|
|
|
|
actions.forEach((action) => {
|
|
this.listRealtimeService.publishSnapshot(userId, action.list);
|
|
});
|
|
|
|
if (
|
|
this.isListCreationRequest(latestUserMessage?.content ?? '') &&
|
|
!actions.some((action) => action.type === 'list.created')
|
|
) {
|
|
return {
|
|
message: {
|
|
role: 'assistant',
|
|
content:
|
|
'Ich konnte die Liste nicht anlegen, weil der Listify-Connector keine Liste erstellt hat.',
|
|
},
|
|
actions: [],
|
|
};
|
|
}
|
|
|
|
if (!content) {
|
|
await this.recordChatLog({
|
|
userId,
|
|
provider: 'listify',
|
|
endpoint: 'assistant:emptyResponse',
|
|
agentId: null,
|
|
requestPayload: {
|
|
latestUserMessage: latestUserMessage?.content,
|
|
actionCount: actions.length,
|
|
},
|
|
responsePayload: { actions },
|
|
statusCode: null,
|
|
durationMs: 0,
|
|
assistantContent: null,
|
|
errorMessage: 'Mistral response was empty.',
|
|
});
|
|
throw new ServiceUnavailableException('Mistral response was empty.');
|
|
}
|
|
|
|
return {
|
|
message: {
|
|
role: 'assistant',
|
|
content,
|
|
},
|
|
actions,
|
|
};
|
|
}
|
|
|
|
async listChatLogs(userId: string): Promise<AssistantChatLog[]> {
|
|
const logs = await this.chatLogsRepository.find({
|
|
where: { userId },
|
|
order: { createdAt: 'DESC' },
|
|
take: 50,
|
|
});
|
|
|
|
return logs.slice(0, 50).map((log) => ({
|
|
id: log.id,
|
|
provider: log.provider,
|
|
endpoint: log.endpoint,
|
|
agentId: log.agentId,
|
|
statusCode: log.statusCode,
|
|
durationMs: log.durationMs,
|
|
requestPayload: log.requestPayload,
|
|
responsePayload: log.responsePayload,
|
|
assistantContent: log.assistantContent,
|
|
errorMessage: log.errorMessage,
|
|
createdAt: log.createdAt.toISOString(),
|
|
}));
|
|
}
|
|
|
|
private async tryHandleLocalListQuery(
|
|
userId: string,
|
|
messages: AssistantChatMessage[],
|
|
): Promise<AssistantChatResponse | null> {
|
|
const latestUserMessage = this.latestUserMessage(messages);
|
|
const content = latestUserMessage?.content.toLowerCase() ?? '';
|
|
|
|
if (!this.isListReadRequest(content)) {
|
|
return null;
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
const lists = await this.listsService.listLists(userId);
|
|
const wantsOpenLists = /\boffen\w*\b/.test(content);
|
|
const visibleLists = wantsOpenLists
|
|
? lists.filter((list) => !this.isCompletedList(list))
|
|
: lists;
|
|
const assistantContent = this.formatListsAnswer(
|
|
visibleLists,
|
|
wantsOpenLists,
|
|
);
|
|
|
|
await this.recordChatLog({
|
|
userId,
|
|
provider: 'listify',
|
|
endpoint: 'local:listLists',
|
|
agentId: null,
|
|
requestPayload: {
|
|
intent: wantsOpenLists ? 'list.open_lists' : 'list.list_lists',
|
|
latestUserMessage: latestUserMessage?.content,
|
|
},
|
|
responsePayload: { lists: visibleLists },
|
|
statusCode: 200,
|
|
durationMs: Date.now() - startedAt,
|
|
assistantContent,
|
|
errorMessage: null,
|
|
});
|
|
|
|
return {
|
|
message: {
|
|
role: 'assistant',
|
|
content: assistantContent,
|
|
},
|
|
actions: [],
|
|
};
|
|
}
|
|
|
|
private async tryHandleLocalListMutation(
|
|
userId: string,
|
|
messages: AssistantChatMessage[],
|
|
context: NormalizedAssistantPageContext | null,
|
|
): Promise<AssistantChatResponse | null> {
|
|
if (context?.page !== 'list_detail') {
|
|
return null;
|
|
}
|
|
|
|
const latestUserMessage = this.latestUserMessage(messages);
|
|
const itemTitle = this.extractItemTitleToAdd(
|
|
latestUserMessage?.content ?? '',
|
|
);
|
|
|
|
if (!itemTitle) {
|
|
return null;
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
const list = await this.listsService.addItem(userId, context.list.id, {
|
|
title: itemTitle,
|
|
});
|
|
const assistantContent = `Ich habe **${itemTitle}** zu **${list.name}** hinzugefuegt.`;
|
|
const action: AssistantAction = {
|
|
type: 'list.item_added',
|
|
listId: list.id,
|
|
itemTitle,
|
|
list,
|
|
};
|
|
|
|
this.listRealtimeService.publishSnapshot(userId, list);
|
|
|
|
await this.recordChatLog({
|
|
userId,
|
|
provider: 'listify',
|
|
endpoint: 'local:addItem',
|
|
agentId: null,
|
|
requestPayload: {
|
|
intent: 'list.add_item',
|
|
latestUserMessage: latestUserMessage?.content,
|
|
listId: context.list.id,
|
|
itemTitle,
|
|
},
|
|
responsePayload: { list },
|
|
statusCode: 200,
|
|
durationMs: Date.now() - startedAt,
|
|
assistantContent,
|
|
errorMessage: null,
|
|
});
|
|
|
|
return {
|
|
message: {
|
|
role: 'assistant',
|
|
content: assistantContent,
|
|
},
|
|
actions: [action],
|
|
};
|
|
}
|
|
|
|
private extractItemTitleToAdd(content: string): string | null {
|
|
const compacted = content.replace(/\s+/g, ' ').trim();
|
|
|
|
if (!compacted) {
|
|
return null;
|
|
}
|
|
|
|
const patterns = [
|
|
/\b(?:fuege|füge)\s+(.+?)\s+(?:hinzu|dazu|drauf|auf die liste|auf diese liste)\b/i,
|
|
/\b(?:schreib|schreibe|notier|notiere|pack|setze|setz)\s+(.+?)(?:\s+(?:drauf|dazu|hinzu|auf die liste|auf diese liste))?$/i,
|
|
/\b(.+?)\s+(?:draufschreiben|aufschreiben|hinzufuegen|hinzufügen)\b/i,
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
const match = compacted.match(pattern);
|
|
const title = this.cleanExtractedItemTitle(match?.[1]);
|
|
|
|
if (title) {
|
|
return title;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private cleanExtractedItemTitle(value: string | undefined): string | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const cleaned = value
|
|
.replace(/^(?:bitte|noch|auch|mal|hier)\s+/i, '')
|
|
.replace(/\s+(?:bitte|noch|auch|mal)$/i, '')
|
|
.replace(/^["'`]+|["'`.,!?]+$/g, '')
|
|
.trim();
|
|
|
|
if (!cleaned || cleaned.length > 220) {
|
|
return null;
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
private isListReadRequest(content: string): boolean {
|
|
const asksForLists = /\b(listen|liste)\b/.test(content);
|
|
const asksToRead =
|
|
/\b(zeig|zeige|anzeigen|abrufen|auflisten|welche|was|habe|gib|nenn)\w*\b/.test(
|
|
content,
|
|
);
|
|
const asksForOpenLists = /\boffen\w*\b/.test(content) && asksForLists;
|
|
const asksForOwnLists = /\bmeine listen\b/.test(content);
|
|
|
|
return asksToRead && (asksForOpenLists || asksForOwnLists);
|
|
}
|
|
|
|
private isListCreationRequest(content: string): boolean {
|
|
const normalized = this.normalizeIntentText(content);
|
|
const mentionsList =
|
|
/\b(list|liste|listen|packliste|einkaufsliste|todo|aufgabenliste)\b/.test(
|
|
normalized,
|
|
);
|
|
const asksToCreate =
|
|
/\b(erstell|erstelle|erstellt|erzeugen|generier|generiere|mach|mache|bau|baue)\w*\b/.test(
|
|
normalized,
|
|
) || /\b(?:leg|lege)\s+.*\b(?:an|neu)\b/.test(normalized);
|
|
|
|
return mentionsList && asksToCreate;
|
|
}
|
|
|
|
private normalizeIntentText(content: string): string {
|
|
return content
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
private isCompletedList(list: UserList): boolean {
|
|
return (
|
|
list.items.length > 0 && list.items.every((item) => item.checked === true)
|
|
);
|
|
}
|
|
|
|
private formatListsAnswer(lists: UserList[], openOnly: boolean): string {
|
|
if (lists.length === 0) {
|
|
return openOnly
|
|
? 'Du hast aktuell keine offenen Listen.'
|
|
: 'Du hast aktuell keine Listen.';
|
|
}
|
|
|
|
const heading = openOnly
|
|
? `Du hast ${lists.length} offene ${lists.length === 1 ? 'Liste' : 'Listen'}:`
|
|
: `Du hast ${lists.length} ${lists.length === 1 ? 'Liste' : 'Listen'}:`;
|
|
const lines = [heading, ''];
|
|
|
|
for (const list of lists) {
|
|
const checkedCount = list.items.filter((item) => item.checked).length;
|
|
const openItems = list.items.filter((item) => !item.checked);
|
|
const ownerSuffix =
|
|
list.accessRole === 'collaborator'
|
|
? `, geteilt von ${list.ownerName || list.ownerEmail || 'Owner'}`
|
|
: '';
|
|
|
|
lines.push(
|
|
`- **${list.name}** (${checkedCount}/${list.items.length} erledigt${ownerSuffix})`,
|
|
);
|
|
|
|
for (const item of openItems.slice(0, 5)) {
|
|
lines.push(` - ${item.title}`);
|
|
}
|
|
|
|
if (openItems.length > 5) {
|
|
lines.push(` - ${openItems.length - 5} weitere offene Punkte`);
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
private async callMistralAgent(
|
|
userId: string,
|
|
messages: AssistantChatMessage[],
|
|
context: NormalizedAssistantPageContext | null,
|
|
): Promise<MistralAgentCompletionResponse> {
|
|
const apiKey = process.env.MISTRAL_API_KEY;
|
|
const agentId = process.env.MISTRAL_AGENT_ID;
|
|
const contextMessage = this.createContextSystemMessage(context);
|
|
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(' '),
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: 'connector',
|
|
connector_id: 'listify',
|
|
},
|
|
],
|
|
stream: false,
|
|
response_format: {
|
|
type: 'text',
|
|
},
|
|
};
|
|
|
|
if (!apiKey) {
|
|
await this.recordChatLog({
|
|
userId,
|
|
agentId: agentId ?? null,
|
|
requestPayload,
|
|
responsePayload: null,
|
|
statusCode: null,
|
|
durationMs: 0,
|
|
assistantContent: null,
|
|
errorMessage: 'Mistral API key is not configured.',
|
|
});
|
|
throw new ServiceUnavailableException(
|
|
'Mistral API key is not configured.',
|
|
);
|
|
}
|
|
|
|
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;
|
|
let assistantContent: string | null = null;
|
|
|
|
const response = await fetch(this.endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestPayload),
|
|
});
|
|
statusCode = response.status;
|
|
responsePayload = await this.readResponsePayload(response);
|
|
|
|
if (!response.ok) {
|
|
await this.recordChatLog({
|
|
userId,
|
|
agentId,
|
|
requestPayload,
|
|
responsePayload,
|
|
statusCode,
|
|
durationMs: Date.now() - startedAt,
|
|
assistantContent,
|
|
errorMessage: 'Mistral agent request failed.',
|
|
});
|
|
throw new ServiceUnavailableException('Mistral agent request failed.');
|
|
}
|
|
|
|
assistantContent = this.extractAssistantContent(responsePayload);
|
|
|
|
await this.recordChatLog({
|
|
userId,
|
|
agentId,
|
|
requestPayload,
|
|
responsePayload,
|
|
statusCode,
|
|
durationMs: Date.now() - startedAt,
|
|
assistantContent,
|
|
errorMessage: null,
|
|
});
|
|
|
|
return responsePayload as MistralAgentCompletionResponse;
|
|
}
|
|
|
|
private async readResponsePayload(response: Response): Promise<unknown> {
|
|
const rawBody = await response.text();
|
|
|
|
if (!rawBody) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(rawBody) as unknown;
|
|
} catch {
|
|
return { rawBody };
|
|
}
|
|
}
|
|
|
|
private extractAssistantContent(responsePayload: unknown): string | null {
|
|
if (!responsePayload || typeof responsePayload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const response = responsePayload as MistralAgentCompletionResponse;
|
|
const directContent = response.choices?.[0]?.message?.content?.trim();
|
|
|
|
if (directContent && !this.looksLikeJson(directContent)) {
|
|
return directContent;
|
|
}
|
|
|
|
for (const choice of response.choices ?? []) {
|
|
const assistantMessages = (choice.messages ?? [])
|
|
.filter((message) => message.role === 'assistant')
|
|
.reverse();
|
|
|
|
for (const message of assistantMessages) {
|
|
const content = this.contentToText(message.content).trim();
|
|
|
|
if (content && !this.looksLikeJson(content)) {
|
|
return content;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private extractActions(responsePayload: unknown): AssistantAction[] {
|
|
if (!responsePayload || typeof responsePayload !== 'object') {
|
|
return [];
|
|
}
|
|
|
|
const actions: AssistantAction[] = [];
|
|
const response = responsePayload as MistralAgentCompletionResponse;
|
|
|
|
for (const choice of response.choices ?? []) {
|
|
const toolNamesById = new Map<string, string>();
|
|
|
|
for (const message of choice.messages ?? []) {
|
|
for (const toolCall of message.tool_calls ?? []) {
|
|
if (toolCall.id && toolCall.function?.name) {
|
|
toolNamesById.set(toolCall.id, toolCall.function.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const message of choice.messages ?? []) {
|
|
if (message.role !== 'tool') {
|
|
continue;
|
|
}
|
|
|
|
const structuredContent = message.metadata?.mcp_meta?.structuredContent;
|
|
const list =
|
|
this.listFromStructuredContent(structuredContent) ??
|
|
this.listFromToolContent(message.content);
|
|
|
|
if (!list) {
|
|
continue;
|
|
}
|
|
|
|
const toolName = message.tool_call_id
|
|
? toolNamesById.get(message.tool_call_id)
|
|
: undefined;
|
|
|
|
if (toolName?.includes('create_list')) {
|
|
actions.push({
|
|
type: 'list.created',
|
|
listId: list.id,
|
|
list,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (toolName?.includes('add_list_item')) {
|
|
actions.push({
|
|
type: 'list.item_added',
|
|
listId: list.id,
|
|
itemTitle: this.lastItemTitle(list),
|
|
list,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
actions.push({
|
|
type: 'list.item_added',
|
|
listId: list.id,
|
|
itemTitle: this.lastItemTitle(list),
|
|
list,
|
|
});
|
|
}
|
|
}
|
|
|
|
return this.uniqueActions(actions);
|
|
}
|
|
|
|
private listFromStructuredContent(value: unknown): UserList | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
|
|
const structuredContent = value as { list?: unknown };
|
|
const list = structuredContent.list;
|
|
|
|
if (!list || typeof list !== 'object' || Array.isArray(list)) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = list as Partial<UserList>;
|
|
|
|
if (
|
|
typeof candidate.id !== 'string' ||
|
|
typeof candidate.name !== 'string' ||
|
|
!Array.isArray(candidate.items)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return candidate as UserList;
|
|
}
|
|
|
|
private listFromToolContent(value: unknown): UserList | null {
|
|
for (const text of this.contentTextParts(value)) {
|
|
try {
|
|
const parsed = JSON.parse(text) as unknown;
|
|
const list = this.listFromStructuredContent(parsed);
|
|
|
|
if (list) {
|
|
return list;
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private contentToText(value: unknown): string {
|
|
return this.contentTextParts(value).join('\n');
|
|
}
|
|
|
|
private contentTextParts(value: unknown): string[] {
|
|
if (typeof value === 'string') {
|
|
return [value];
|
|
}
|
|
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.map((part) => {
|
|
if (typeof part === 'string') {
|
|
return part;
|
|
}
|
|
|
|
if (!part || typeof part !== 'object' || Array.isArray(part)) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = part as { text?: unknown };
|
|
|
|
return typeof candidate.text === 'string' ? candidate.text : null;
|
|
})
|
|
.filter((part): part is string => part !== null);
|
|
}
|
|
|
|
private looksLikeJson(content: string): boolean {
|
|
const trimmed = content.trim();
|
|
|
|
return (
|
|
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
|
);
|
|
}
|
|
|
|
private createActionContent(actions: AssistantAction[]): string | null {
|
|
const createdList = actions.find(
|
|
(action) => action.type === 'list.created',
|
|
);
|
|
|
|
if (createdList) {
|
|
return `Ich habe die Liste **${createdList.list.name}** angelegt.`;
|
|
}
|
|
|
|
const addedItem = actions.find(
|
|
(action) => action.type === 'list.item_added',
|
|
);
|
|
|
|
if (addedItem) {
|
|
return `Ich habe **${addedItem.itemTitle}** zu **${addedItem.list.name}** hinzugefuegt.`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private lastItemTitle(list: UserList): string {
|
|
return list.items.at(-1)?.title ?? 'Eintrag';
|
|
}
|
|
|
|
private uniqueActions(actions: AssistantAction[]): AssistantAction[] {
|
|
const seen = new Set<string>();
|
|
|
|
return actions.filter((action) => {
|
|
const key = `${action.type}:${action.listId}:${action.type === 'list.item_added' ? action.itemTitle : ''}`;
|
|
|
|
if (seen.has(key)) {
|
|
return false;
|
|
}
|
|
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private async recordChatLog(input: {
|
|
userId: string;
|
|
provider?: string;
|
|
endpoint?: string;
|
|
agentId: string | null;
|
|
requestPayload: Record<string, unknown>;
|
|
responsePayload: unknown;
|
|
statusCode: number | null;
|
|
durationMs: number;
|
|
assistantContent: string | null;
|
|
errorMessage: string | null;
|
|
}): Promise<void> {
|
|
await this.chatLogsRepository.save(
|
|
this.chatLogsRepository.create({
|
|
id: randomUUID(),
|
|
userId: input.userId,
|
|
provider: input.provider ?? 'mistral',
|
|
endpoint: input.endpoint ?? this.endpoint,
|
|
agentId: input.agentId,
|
|
statusCode: input.statusCode,
|
|
durationMs: input.durationMs,
|
|
requestPayload: input.requestPayload,
|
|
responsePayload: input.responsePayload,
|
|
assistantContent: input.assistantContent,
|
|
errorMessage: input.errorMessage,
|
|
}),
|
|
);
|
|
}
|
|
|
|
private latestUserMessage(
|
|
messages: AssistantChatMessage[],
|
|
): AssistantChatMessage | undefined {
|
|
return [...messages].reverse().find((message) => message.role === 'user');
|
|
}
|
|
|
|
private normalizeMessages(
|
|
messages: AssistantChatMessage[] | undefined,
|
|
): AssistantChatMessage[] {
|
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
throw new BadRequestException('At least one chat message is required.');
|
|
}
|
|
|
|
return messages.slice(-12).map((message) => {
|
|
if (message.role !== 'user' && message.role !== 'assistant') {
|
|
throw new BadRequestException('Chat message role is invalid.');
|
|
}
|
|
|
|
const content = message.content?.trim();
|
|
|
|
if (!content) {
|
|
throw new BadRequestException('Chat message content is required.');
|
|
}
|
|
|
|
return { role: message.role, content };
|
|
});
|
|
}
|
|
|
|
private normalizeContext(
|
|
context: AssistantPageContext | undefined,
|
|
): NormalizedAssistantPageContext | null {
|
|
if (!context || typeof context !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const route = this.compactString(context.route, 240) ?? '';
|
|
|
|
if (context.page === 'lists_overview') {
|
|
return { page: 'lists_overview', route };
|
|
}
|
|
|
|
if (context.page === 'templates_overview') {
|
|
return { page: 'templates_overview', route };
|
|
}
|
|
|
|
if (context.page === 'unknown') {
|
|
return { page: 'unknown', route };
|
|
}
|
|
|
|
if (context.page === 'list_detail') {
|
|
const list = this.normalizeListContext(context.list);
|
|
|
|
return list ? { page: 'list_detail', route, list } : null;
|
|
}
|
|
|
|
if (context.page === 'template_detail') {
|
|
const template = this.normalizeTemplateContext(context.template);
|
|
|
|
return template ? { page: 'template_detail', route, template } : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private normalizeListContext(list: UserList): NormalizedContextList | null {
|
|
if (!list || typeof list !== 'object' || Array.isArray(list)) {
|
|
return null;
|
|
}
|
|
|
|
const id = this.compactString(list.id, 120);
|
|
const name = this.compactString(list.name, 160);
|
|
const kind = this.compactString(list.kind, 80);
|
|
|
|
if (!id || !name || !kind || !Array.isArray(list.items)) {
|
|
return null;
|
|
}
|
|
|
|
const items = list.items
|
|
.slice(0, 40)
|
|
.map((item) => this.normalizeItemContext(item, true))
|
|
.filter((item): item is NormalizedContextItem => item !== null);
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
kind,
|
|
description: this.compactString(list.description, 240),
|
|
items,
|
|
omittedItems: Math.max(0, list.items.length - items.length),
|
|
};
|
|
}
|
|
|
|
private normalizeTemplateContext(
|
|
template: ListTemplate,
|
|
): NormalizedContextTemplate | null {
|
|
if (!template || typeof template !== 'object' || Array.isArray(template)) {
|
|
return null;
|
|
}
|
|
|
|
const id = this.compactString(template.id, 120);
|
|
const name = this.compactString(template.name, 160);
|
|
const kind = this.compactString(template.kind, 80);
|
|
|
|
if (!id || !name || !kind || !Array.isArray(template.items)) {
|
|
return null;
|
|
}
|
|
|
|
const items = template.items
|
|
.slice(0, 40)
|
|
.map((item) => this.normalizeItemContext(item, false))
|
|
.filter((item): item is NormalizedContextItem => item !== null);
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
kind,
|
|
description: this.compactString(template.description, 240),
|
|
items,
|
|
omittedItems: Math.max(0, template.items.length - items.length),
|
|
};
|
|
}
|
|
|
|
private normalizeItemContext(
|
|
item: Partial<UserListItem>,
|
|
includeChecked: boolean,
|
|
): NormalizedContextItem | null {
|
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
return null;
|
|
}
|
|
|
|
const id = this.compactString(item.id, 120);
|
|
const title = this.compactString(item.title, 160);
|
|
|
|
if (!id || !title) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
title,
|
|
notes: this.compactString(item.notes, 220),
|
|
quantity: typeof item.quantity === 'number' ? item.quantity : undefined,
|
|
required: typeof item.required === 'boolean' ? item.required : undefined,
|
|
checked:
|
|
includeChecked && typeof item.checked === 'boolean'
|
|
? item.checked
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
private createContextSystemMessage(
|
|
context: NormalizedAssistantPageContext | null,
|
|
): MistralMessage | null {
|
|
if (!context) {
|
|
return null;
|
|
}
|
|
|
|
const lines = ['Aktueller Listify-Kontext:'];
|
|
|
|
if (context.page === 'lists_overview') {
|
|
lines.push(`Der User befindet sich auf der Listenuebersicht.`);
|
|
lines.push(`Route: ${context.route}`);
|
|
return { role: 'system', content: lines.join('\n') };
|
|
}
|
|
|
|
if (context.page === 'templates_overview') {
|
|
lines.push(`Der User befindet sich auf der Vorlagenuebersicht.`);
|
|
lines.push(`Route: ${context.route}`);
|
|
return { role: 'system', content: lines.join('\n') };
|
|
}
|
|
|
|
if (context.page === 'unknown') {
|
|
lines.push(`Die aktuelle Seite ist nicht eindeutig zugeordnet.`);
|
|
lines.push(`Route: ${context.route}`);
|
|
return { role: 'system', content: lines.join('\n') };
|
|
}
|
|
|
|
if (context.page === 'list_detail') {
|
|
lines.push(`Der User befindet sich auf einer Listendetailseite.`);
|
|
lines.push(`Route: ${context.route}`);
|
|
lines.push(this.formatListContext(context.list));
|
|
lines.push(
|
|
`Wenn der User "diese Liste", "hier" oder aehnliche Verweise nutzt, bezieht sich das auf die Liste mit ID ${context.list.id}.`,
|
|
);
|
|
return { role: 'system', content: lines.join('\n') };
|
|
}
|
|
|
|
lines.push(`Der User befindet sich auf einer Vorlagendetailseite.`);
|
|
lines.push(`Route: ${context.route}`);
|
|
lines.push(this.formatTemplateContext(context.template));
|
|
lines.push(
|
|
`Wenn der User "diese Vorlage", "hier" oder aehnliche Verweise nutzt, bezieht sich das auf die Vorlage mit ID ${context.template.id}.`,
|
|
);
|
|
|
|
return { role: 'system', content: lines.join('\n') };
|
|
}
|
|
|
|
private formatListContext(list: NormalizedContextList): string {
|
|
return [
|
|
`Offene Liste: ${list.name} (ID: ${list.id}, Typ: ${list.kind})`,
|
|
...(list.description ? [`Beschreibung: ${list.description}`] : []),
|
|
...this.formatItems(list.items, list.omittedItems, true),
|
|
].join('\n');
|
|
}
|
|
|
|
private formatTemplateContext(template: NormalizedContextTemplate): string {
|
|
return [
|
|
`Offene Vorlage: ${template.name} (ID: ${template.id}, Typ: ${template.kind})`,
|
|
...(template.description
|
|
? [`Beschreibung: ${template.description}`]
|
|
: []),
|
|
...this.formatItems(template.items, template.omittedItems, false),
|
|
].join('\n');
|
|
}
|
|
|
|
private formatItems(
|
|
items: NormalizedContextItem[],
|
|
omittedItems: number,
|
|
includeChecked: boolean,
|
|
): string[] {
|
|
if (items.length === 0) {
|
|
return ['Items: keine'];
|
|
}
|
|
|
|
const lines = ['Items:'];
|
|
|
|
for (const item of items) {
|
|
const parts = [
|
|
`ID: ${item.id}`,
|
|
typeof item.quantity === 'number' ? `Menge: ${item.quantity}` : null,
|
|
typeof item.required === 'boolean'
|
|
? `Pflicht: ${item.required ? 'ja' : 'nein'}`
|
|
: null,
|
|
includeChecked && typeof item.checked === 'boolean'
|
|
? `Erledigt: ${item.checked ? 'ja' : 'nein'}`
|
|
: null,
|
|
item.notes ? `Notizen: ${item.notes}` : null,
|
|
].filter((part): part is string => part !== null);
|
|
|
|
lines.push(`- ${item.title} (${parts.join(', ')})`);
|
|
}
|
|
|
|
if (omittedItems > 0) {
|
|
lines.push(`- ${omittedItems} weitere Eintraege ausgelassen.`);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
private compactString(
|
|
value: string | undefined,
|
|
maxLength: number,
|
|
): string | undefined {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const compacted = value.replace(/\s+/g, ' ').trim();
|
|
|
|
if (!compacted) {
|
|
return undefined;
|
|
}
|
|
|
|
if (compacted.length <= maxLength) {
|
|
return compacted;
|
|
}
|
|
|
|
return `${compacted.slice(0, maxLength - 3)}...`;
|
|
}
|
|
}
|