feat: add chat assistant with role-aware prompt and vision support
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user