chat
This commit is contained in:
@@ -6,16 +6,61 @@ import {
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserList } from '../list-templates/list-template.types';
|
||||
import {
|
||||
ListTemplate,
|
||||
UserList,
|
||||
UserListItem,
|
||||
} from '../list-templates/list-template.types';
|
||||
import { ListRealtimeService } from '../lists/list-realtime.service';
|
||||
import { AssistantChatLogEntity } from './assistant-chat-log.entity';
|
||||
import {
|
||||
AssistantAction,
|
||||
AssistantChatMessage,
|
||||
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?: {
|
||||
@@ -55,7 +100,8 @@ export class AssistantService {
|
||||
request: AssistantChatRequest,
|
||||
): Promise<AssistantChatResponse> {
|
||||
const messages = this.normalizeMessages(request.messages);
|
||||
const response = await this.callMistralAgent(userId, messages);
|
||||
const context = this.normalizeContext(request.context);
|
||||
const response = await this.callMistralAgent(userId, messages, context);
|
||||
const content = this.extractAssistantContent(response);
|
||||
const actions = this.extractActions(response);
|
||||
|
||||
@@ -79,6 +125,7 @@ export class AssistantService {
|
||||
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;
|
||||
@@ -91,16 +138,18 @@ export class AssistantService {
|
||||
throw new ServiceUnavailableException('Mistral agent id is not configured.');
|
||||
}
|
||||
|
||||
const contextMessage = this.createContextSystemMessage(context);
|
||||
const requestPayload = {
|
||||
agent_id: agentId,
|
||||
messages: [
|
||||
...messages,
|
||||
{ "role": "system", "content": "benutze immer den listify connector"}
|
||||
...(contextMessage ? [contextMessage] : []),
|
||||
{ role: 'system', content: 'benutze immer den listify connector' },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
"type": "connector",
|
||||
"connector_id": "listify"
|
||||
type: 'connector',
|
||||
connector_id: 'listify',
|
||||
}
|
||||
],
|
||||
stream: false,
|
||||
@@ -356,4 +405,246 @@ export class AssistantService {
|
||||
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)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user