feat: add chat assistant with role-aware prompt and vision support

This commit is contained in:
MrFrostDev
2026-05-23 13:58:27 +02:00
parent 8aa47f1ff7
commit e4f3ccfaaf
10 changed files with 2200 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
import { MOCK_TOOLS, MOCK_MANUALS } from './mock-data';
import type { Reservation, Tool } from '../types';
export type ChatRole = 'user' | 'assistant' | 'system';
export interface ChatAttachment {
name: string;
type: string;
dataUrl: string;
}
/**
* Incoming message from the client. content is plain text, attachments
* carry images as Data-URLs that we convert to OpenAI multimodal format
* before calling the model.
*/
export interface ChatMessage {
role: ChatRole;
content: string;
attachments?: ChatAttachment[];
}
export interface ChatContext {
userRole?: 'admin' | 'staff' | 'user' | 'guest';
username?: string;
tools?: Tool[];
reservations?: Reservation[];
users?: Array<{ id: string; username: string; role: string }>;
}
export interface ChatResult {
content: string;
mode: 'live' | 'mock' | 'mock-fallback';
matchedTools?: Tool[];
sources?: string[];
}
/* ---------- system prompt builder ---------- */
const BASE_PROMPT = `# RepairBuddy Systemprompt
## Identität & Rolle
Du bist **RepairBuddy**, ein freundlicher und sachkundiger Assistent mit zwei Kernaufgaben:
1. **Reparaturhilfe** Du unterstützt Nutzer dabei, Defekte zu identifizieren und mögliche Reparaturansätze zu verstehen.
2. **Werkzeugverwaltung** Du hast Zugriff auf eine Werkzeugausleihe-Datenbank und beantwortest Anfragen zu Verfügbarkeit, Standort und Ausleihe direkt und präzise.
Du bist kein zertifizierter Fachbetrieb und ersetzt keine professionelle Fachkraft. Alle Reparaturhinweise sind unverbindlich und ohne Gewähr.
Du antwortest stets in der Sprache, in der der Nutzer schreibt.
Du formatierst deine Antworten in **Markdown** (Überschriften, Listen, Hervorhebungen, Tabellen bei mehreren Datenbankeinträgen).
## Kernaufgabe 1 Werkzeugverwaltung
### Grundprinzip
Bei Datenbankabfragen gilt: **direkte, präzise Fakten aus der Datenbank kein Smalltalk, keine allgemeinen Erklärungen.** Die Datenbank ist die Quelle der Wahrheit. Gib nur aus, was sie enthält.
Ist eine Information nicht in der Datenbank vorhanden, sage das knapp: „Dazu liegt kein Eintrag vor."
### Verwalter-Funktionen
Ein Verwalter kann Übersicht, Einzel-, Personen- und Fälligkeitsabfragen stellen sowie Ausleihen, Rückgaben und Wartungen anweisen.
**Antwortformat für Verwalter:** Kompakt, tabellarisch bei mehreren Einträgen, sonst eine Zeile pro Werkzeug. Keine Einleitungssätze.
### Nutzer-Funktionen
Ein normaler Nutzer darf nur Verfügbarkeit, voraussichtliches Rückgabedatum und die Liste verfügbarer Werkzeuge erfahren.
**Nicht ausgeben an Nutzer:** Namen von Ausleihenden, Abteilungen, Ausleihzeitpunkt, interne Vermerke.
**Antwortformat für Nutzer:** Ein bis zwei Sätze, direkt.
### Fehler- und Grenzfälle
- Werkzeug nicht in der Datenbank → „Inventarnummer / Bezeichnung nicht gefunden. Bitte prüfen."
- Rückgabedatum unbekannt → „Ausgeliehen, kein Rückgabedatum eingetragen."
- Nutzer fragt nach Verwalter-Daten → „Diese Information ist nur für Verwalter einsehbar."
- Aktion (Buchen, Rückgabe) durch Nutzer → „Buchungen können nur von Verwaltern vorgenommen werden."
## Kernaufgabe 2 Reparaturhilfe
### Aufgaben
- **Problemidentifikation** anhand Beschreibung oder Bild.
- **Reparaturansätze aufzeigen** Richtung, keine vollständige Anleitung.
- **Orientierung geben** selbst machbar oder Fachmann nötig?
- **Bildanalyse:** Wenn der Nutzer ein Bild mitschickt, beschreibe was du erkennst, leite mögliche Ursachen ab und behandle visuelle Diagnosen als Indizien — Fotos zeigen selten das vollständige Schadensbild.
### Kommunikationsstil
- Freundlich, klar, auf Augenhöhe.
- Fachbegriffe kurz erklären.
- Strukturiert: **Problemzusammenfassung → mögliche Ursache(n) → Ansatz oder Empfehlung.** Maximal drei Abschnitte.
- Maximal eine Rückfrage pro Antwort. Bei vagen Beschreibungen trotzdem eine erste Einschätzung liefern.
## Ehrlichkeit & Sicherheitsgrundsätze (höchste Priorität)
### Notfallregel
Beschreibt ein Nutzer eine akute Gefahrensituation (Gasleck, Stromschlag, Wasserschaden, Brandgeruch), gib **keine** Reparaturhinweise. Weise sofort auf Notfalldienste hin: **Notruf 112** (EU-weit).
### Umgang mit Unsicherheit
Fehlen Gerät, Modell, Baujahr oder eine genaue Fehlerbeschreibung, frage nach oder kennzeichne Aussagen klar als Hypothese: „Eine mögliche Ursache wäre …" / „Das könnte auf … hindeuten."
### Keine vollständigen Anleitungen
Ansätze und Richtungen keine vollständigen Schritt-für-Schritt-Anleitungen.
### Fachkraft empfehlen
Bei Fachkenntnissen, Spezialwerkzeug, Sicherheitsrisiken oder unbekanntem Gebiet aktiv auf Fachleute verweisen.
## Thematische Abdeckung
- Haushaltsgeräte (außer Gas / Druckleitungen)
- Elektronik & IT
- Heimwerken (keine tragenden Wände, keine Elektroinstallation)
- Fahrrad (Schaltung, Bremsen, Reifen, Kette)
- KFZ (nur Glühbirnen, Scheibenwischer, Luftdruck, Flüssigkeiten KEINE Bremse / Lenkung / Airbag / TÜV)
- Kleidung & Textilien
- Garten & Outdoor
## Ablehnungsregeln
Keine konkreten Anweisungen bei stromführenden Leitungen, Gasinstallationen, tragenden Bauteilen, zulassungspflichtigen Arbeiten oder allem außerhalb der Wissensbasis.`;
function mapRoleToContext(role?: ChatContext['userRole']): 'Verwalter' | 'Nutzer' {
return role === 'admin' ? 'Verwalter' : 'Nutzer';
}
function formatToolsBlock(tools: Tool[], reservations: Reservation[], users: ChatContext['users']) {
if (!tools.length) return 'Werkzeug-Inventar ist leer.';
const today = new Date().toISOString().split('T')[0];
const byTool = new Map<string, Reservation[]>();
for (const r of reservations) {
if (r.status === 'completed') continue;
const list = byTool.get(r.toolId) ?? [];
list.push(r);
byTool.set(r.toolId, list);
}
for (const list of byTool.values()) {
list.sort((a, b) => a.startDate.localeCompare(b.startDate));
}
const userById = new Map((users ?? []).map((u) => [u.id, u.username]));
const lines = [
'| Inventar-Nr | Werkzeug | Kategorie | Standort | Status | Ausgeliehen an | Rückgabe |',
'|---|---|---|---|---|---|---|',
];
for (const tool of tools) {
const open = byTool.get(tool.id) ?? [];
const active =
open.find((r) => r.startDate <= today && r.endDate >= today && (r.status === 'approved' || r.status === 'active')) ??
null;
const upcoming = open.find((r) => r.startDate > today && (r.status === 'approved' || r.status === 'requested'));
let status: string;
let borrower = '—';
let dueBack = '—';
if (active) {
status = active.status === 'active' ? 'ausgeliehen' : 'reserviert';
borrower = userById.get(active.userId) ?? `User ${active.userId}`;
dueBack = active.endDate;
} else if (upcoming) {
status = `reserviert ab ${upcoming.startDate}`;
borrower = userById.get(upcoming.userId) ?? `User ${upcoming.userId}`;
dueBack = upcoming.endDate;
} else {
status = 'verfügbar';
}
lines.push(
`| ${tool.id} | ${tool.emoji} ${tool.name} | ${tool.category} | ${tool.location} | ${status} | ${borrower} | ${dueBack} |`
);
}
return lines.join('\n');
}
function formatManualsBlock(): string {
if (!MOCK_MANUALS.length) return '';
const lines = MOCK_MANUALS.map(
(m) => `- **${m.title ?? m.toolId}** (Werkzeug ${m.toolId}): ${m.content}`
);
return ['## Interne Bedienungsanleitungen (Auszug)', ...lines].join('\n');
}
export function buildSystemPrompt(context: ChatContext): string {
const role = mapRoleToContext(context.userRole);
const tools = context.tools ?? [];
const reservations = context.reservations ?? [];
const head = [BASE_PROMPT];
head.push('## Aktueller Kontext');
head.push(`- ROLLE: ${role}`);
if (context.username) head.push(`- ANGEMELDET ALS: ${context.username}`);
head.push(`- HEUTE: ${new Date().toISOString().split('T')[0]}`);
head.push('## Werkzeug-Datenbank (Live-Snapshot)');
head.push(formatToolsBlock(tools, reservations, context.users));
const manuals = formatManualsBlock();
if (manuals) head.push(manuals);
head.push(
role === 'Verwalter'
? '## Aktive Berechtigungen\nDu darfst Personen-Daten, Ausleihhistorie, Fälligkeiten und Buchungsaktionen offenlegen.'
: '## Aktive Berechtigungen\nDu darfst NUR Verfügbarkeit und voraussichtliches Rückgabedatum offenlegen. KEINE Namen, Ausleihzeitpunkte oder Buchungs-Aktionen.'
);
return head.join('\n\n');
}
/* ---------- multimodal API payload ---------- */
type ApiContentPart =
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } };
interface ApiMessage {
role: ChatRole;
content: string | ApiContentPart[];
}
function toApiMessage(message: ChatMessage): ApiMessage {
const imageAttachments = (message.attachments ?? []).filter((a) =>
a.type.startsWith('image/')
);
if (imageAttachments.length === 0) {
return { role: message.role, content: message.content };
}
const parts: ApiContentPart[] = [];
if (message.content && message.content.trim().length > 0) {
parts.push({ type: 'text', text: message.content });
} else {
parts.push({ type: 'text', text: 'Bitte analysiere die mitgeschickten Bilder.' });
}
for (const att of imageAttachments) {
parts.push({ type: 'image_url', image_url: { url: att.dataUrl } });
}
return { role: message.role, content: parts };
}
/* ---------- mock fallback ---------- */
function tokenize(input: string): string[] {
return input
.toLowerCase()
.replace(/[^a-zäöüß0-9\s]/gi, ' ')
.split(/\s+/)
.filter((t) => t.length > 2);
}
function scoreTool(tool: Tool, tokens: string[]): number {
const hay = `${tool.name} ${tool.description} ${tool.location} ${tool.category}`.toLowerCase();
return tokens.reduce((acc, tok) => (hay.includes(tok) ? acc + 1 : acc), 0);
}
export function generateMockReply(
query: string,
history: ChatMessage[],
context: ChatContext = {}
): ChatResult {
const q = query.toLowerCase().trim();
const tools = context.tools ?? MOCK_TOOLS;
const isAdmin = context.userRole === 'admin';
const tokens = tokenize(q);
const lastMsg = history[history.length - 1];
const hasImage =
lastMsg?.attachments?.some((a) => a.type.startsWith('image/')) ?? false;
if (hasImage) {
return {
content:
'Ich habe das Bild erhalten. Im Mock-Modus kann ich Bilder leider nicht analysieren — schalte die Live-API ein, um echte Bildanalyse zu nutzen.',
mode: 'mock',
};
}
if (!q) {
return {
content: 'Beschreibe kurz, was du reparieren möchtest oder welches Werkzeug du suchst.',
mode: 'mock',
};
}
if (/hallo|hi|hey|servus|moin/.test(q)) {
return {
content: 'Hallo! Ich bin **RepairBuddy** — Reparaturhilfe & Werkzeugverwaltung in einem. Wobei kann ich dir helfen?',
mode: 'mock',
};
}
const ranked = tools
.map((t) => ({ t, s: scoreTool(t, tokens) }))
.filter((r) => r.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, 4)
.map((r) => r.t);
if (ranked.length > 0) {
const lines = ranked.map(
(t) => `- ${t.emoji} **${t.name}** (${t.id}) — ${t.location}, max. ${t.maxRentalDays} Tage`
);
return {
content: `Treffer aus dem Inventar:\n\n${lines.join('\n')}${
isAdmin
? '\n\n_Als Verwalter kannst du Buchungen direkt anweisen._'
: '\n\n_Für Buchungen wende dich an einen Verwalter._'
}`,
mode: 'mock',
matchedTools: ranked,
};
}
if (/notruf|112|gefahr|gas|brand|stromschlag/.test(q)) {
return {
content:
'⚠️ **Bei akuter Gefahr sofort Notdienste verständigen.**\n\n- Notruf: **112** (EU-weit)\n- Gas-Notdienst des lokalen Versorgers\n\nErst wenn die Lage gesichert ist, kann eine Diagnose sinnvoll sein.',
mode: 'mock',
};
}
if (history.length > 2) {
return {
content:
'Dazu finde ich keinen spezifischen Treffer im Inventar. Magst du genauer beschreiben (Marke, Modell, Symptom, seit wann)?',
mode: 'mock',
};
}
return {
content:
'Ich konnte dazu keinen direkten Treffer finden. Du kannst nach Inventar-Nummer (z. B. `t1`), Werkzeug-Namen oder einer Reparatur-Aufgabe fragen.',
mode: 'mock',
};
}
/* ---------- live model call ---------- */
export async function callLiveModel(
messages: ChatMessage[],
context: ChatContext,
config: { apiKey: string; baseUrl: string; model: string }
): Promise<string> {
const url = `${config.baseUrl.replace(/\/$/, '')}/chat/completions`;
const systemPrompt = buildSystemPrompt(context);
const apiMessages: ApiMessage[] = [
{ role: 'system', content: systemPrompt },
...messages.map(toApiMessage),
];
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages: apiMessages,
stream: false,
}),
});
if (!response.ok) {
throw new Error(`Upstream ${response.status}: ${await response.text()}`);
}
const data = await response.json();
const content = data?.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('Unexpected upstream response shape');
}
return content;
}
export function isLiveConfigPresent() {
return Boolean(process.env.GEMINI_API_KEY && process.env.REQUESTY_BASE_URL);
}
+98
View File
@@ -0,0 +1,98 @@
'use client';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export interface MessageAttachment {
name: string;
type: string;
dataUrl: string;
size: number;
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
attachments?: MessageAttachment[];
timestamp: number;
}
export interface ChatSession {
id: string;
title: string;
messages: Message[];
createdAt: number;
}
interface ChatState {
sessions: ChatSession[];
activeSessionId: string | null;
addMessage: (sessionId: string, message: Omit<Message, 'id' | 'timestamp'>) => void;
createSession: (title: string) => string;
setActiveSession: (id: string | null) => void;
deleteSession: (id: string) => void;
renameSession: (id: string, title: string) => void;
clearAll: () => void;
}
function randomId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return Math.random().toString(36).slice(2, 11);
}
export const useChatStore = create<ChatState>()(
persist(
(set) => ({
sessions: [],
activeSessionId: null,
createSession: (title) => {
const id = randomId();
const trimmed = (title || 'Neue Reparatur').slice(0, 60);
set((state) => ({
sessions: [
{ id, title: trimmed, messages: [], createdAt: Date.now() },
...state.sessions,
],
activeSessionId: id,
}));
return id;
},
setActiveSession: (id) => set({ activeSessionId: id }),
addMessage: (sessionId, msg) => {
set((state) => ({
sessions: state.sessions.map((s) =>
s.id === sessionId
? {
...s,
messages: [
...s.messages,
{ ...msg, id: randomId(), timestamp: Date.now() },
],
}
: s
),
}));
},
deleteSession: (id) =>
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== id),
activeSessionId: state.activeSessionId === id ? null : state.activeSessionId,
})),
renameSession: (id, title) =>
set((state) => ({
sessions: state.sessions.map((s) =>
s.id === id ? { ...s, title: title.slice(0, 60) } : s
),
})),
clearAll: () => set({ sessions: [], activeSessionId: null }),
}),
{
name: 'patchpilot:chat',
storage: createJSONStorage(() => localStorage),
version: 2,
}
)
);
+62
View File
@@ -0,0 +1,62 @@
/**
* Reads a File and returns a Data-URL. Optionally downscales large images
* via an offscreen canvas to stay under the localStorage quota.
*/
export async function fileToDataUrl(
file: File,
options: { maxDimension?: number; maxSize?: number; jpegQuality?: number } = {}
): Promise<{ dataUrl: string; type: string; size: number }> {
const { maxDimension = 1600, maxSize = 2_500_000, jpegQuality = 0.85 } = options;
// Non-image files: passthrough as data-url
if (!file.type.startsWith('image/')) {
const dataUrl = await readAsDataUrl(file);
return { dataUrl, type: file.type, size: file.size };
}
// For small enough images, skip the canvas dance
if (file.size < maxSize) {
const dataUrl = await readAsDataUrl(file);
return { dataUrl, type: file.type, size: file.size };
}
const dataUrl = await readAsDataUrl(file);
const img = await loadImage(dataUrl);
let { width, height } = img;
if (width > maxDimension || height > maxDimension) {
const ratio = Math.min(maxDimension / width, maxDimension / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return { dataUrl, type: file.type, size: file.size };
ctx.drawImage(img, 0, 0, width, height);
const outType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const downscaled = canvas.toDataURL(outType, jpegQuality);
return { dataUrl: downscaled, type: outType, size: Math.round(downscaled.length * 0.75) };
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = dataUrl;
});
}
+42
View File
@@ -0,0 +1,42 @@
import { MOCK_TOOLS, MOCK_MANUALS } from './mock-data';
/**
* Simuliert die RAG-Funktionalität (Qdrant + LLM)
*/
export const ragService = {
/**
* Simuliert eine semantische Suche über Werkzeuge und Anleitungen.
*/
async search(query: string) {
const q = query.toLowerCase();
// Einfaches semantisches Matching simulieren
const results = MOCK_TOOLS.filter(t =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q)
);
const manualHints = MOCK_MANUALS.filter(m =>
m.content.toLowerCase().includes(q)
);
return {
answer: this.generateAnswer(q, results, manualHints),
tools: results,
sources: manualHints.length > 0 ? ['Interne Bedienungsanleitungen'] : []
};
},
generateAnswer(query: string, tools: any[], manuals: any[]) {
if (query.includes('bohr')) {
return "Um Löcher in Wände zu bohren, empfehle ich die Bosch Schlagbohrmaschine. Laut Anleitung müssen Sie das Spannfutter festziehen und den passenden Bohrer wählen.";
}
if (query.includes('3d') || query.includes('druck')) {
return "Für 3D-Drucke haben wir den Prusa i3. Wichtig: Das Druckbett muss vor Nutzung gereinigt werden.";
}
if (tools.length > 0) {
return `Ich habe ${tools.length} passende Werkzeuge gefunden: ${tools.map(t => t.name).join(', ')}.`;
}
return "Ich konnte leider keine spezifische Anleitung dazu finden. Bitte frage einen Werkstattbetreuer vor Ort.";
}
};