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
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import {
callLiveModel,
generateMockReply,
isLiveConfigPresent,
type ChatContext,
type ChatMessage,
} from '../../../services/chat-engine';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface RequestBody {
messages?: ChatMessage[];
context?: ChatContext;
}
export async function POST(req: Request) {
let body: RequestBody;
try {
body = (await req.json()) as RequestBody;
} catch {
return NextResponse.json(
{ error: 'Invalid JSON body', mode: 'error' },
{ status: 400 }
);
}
const messages = Array.isArray(body.messages) ? body.messages : [];
const context = body.context ?? {};
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
const userQuery = lastUser?.content ?? '';
if (!isLiveConfigPresent()) {
const result = generateMockReply(userQuery, messages, context);
return NextResponse.json(result);
}
try {
const content = await callLiveModel(messages, context, {
apiKey: process.env.GEMINI_API_KEY as string,
baseUrl: process.env.REQUESTY_BASE_URL as string,
model: process.env.PATCHPILOT_MODEL || 'vertex/gemini-3-flash-preview',
});
return NextResponse.json({ content, mode: 'live' });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[PatchPilot] Live model failed, falling back to mock:', message);
const fallback = generateMockReply(userQuery, messages, context);
return NextResponse.json({ ...fallback, mode: 'mock-fallback' });
}
}
export async function GET() {
return NextResponse.json({
status: 'ok',
live: isLiveConfigPresent(),
runtime: 'nodejs',
});
}
+269
View File
@@ -0,0 +1,269 @@
'use client';
import { useState, useRef } from 'react';
import {
Box,
IconButton,
Tooltip,
CircularProgress,
styled,
alpha,
} from '@mui/material';
import { Send, Paperclip, Mic, X, Sparkles } from 'lucide-react';
import { useI18n } from '../services/i18n';
const Shell = styled('div')(({ theme }) => ({
position: 'relative',
width: '100%',
borderRadius: 20,
background:
theme.palette.mode === 'light'
? theme.palette.background.paper
: alpha(theme.palette.common.white, 0.04),
border: `1px solid ${theme.palette.divider}`,
boxShadow:
theme.palette.mode === 'light'
? '0 1px 2px rgba(15,23,42,0.04), 0 6px 18px -8px rgba(15,23,42,0.10)'
: '0 1px 2px rgba(0,0,0,0.3), 0 6px 18px -8px rgba(0,0,0,0.5)',
transition: 'border-color 0.18s ease, box-shadow 0.18s ease',
'&:focus-within': {
borderColor: theme.palette.primary.main,
boxShadow: `0 0 0 4px ${alpha(theme.palette.primary.main, 0.18)}, 0 8px 24px -8px ${alpha(
theme.palette.primary.main,
0.25
)}`,
},
}));
const TextArea = styled('textarea')(({ theme }) => ({
width: '100%',
minHeight: 28,
maxHeight: 220,
border: 'none',
outline: 'none',
resize: 'none',
background: 'transparent',
color: theme.palette.text.primary,
fontFamily: 'inherit',
fontSize: '1rem',
lineHeight: 1.55,
padding: 0,
'&::placeholder': {
color: theme.palette.text.secondary,
opacity: 0.7,
},
}));
interface AIInputProps {
onSend: (message: string, files?: File[]) => void;
isLoading?: boolean;
/** Compact = no top padding, smaller font (for chat footer) */
compact?: boolean;
}
export default function AIInput({ onSend, isLoading, compact }: AIInputProps) {
const [input, setInput] = useState('');
const [files, setFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useI18n();
const canSend = (input.trim().length > 0 || files.length > 0) && !isLoading;
const autoGrow = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(220, el.scrollHeight) + 'px';
};
const handleSend = () => {
if (!canSend) return;
onSend(input.trim(), files);
setInput('');
setFiles([]);
if (textareaRef.current) textareaRef.current.style.height = 'auto';
};
const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
setFiles((prev) => [...prev, ...Array.from(e.target.files!)].slice(0, 5));
e.target.value = '';
};
const removeFile = (idx: number) =>
setFiles((prev) => prev.filter((_, i) => i !== idx));
return (
<Box sx={{ width: '100%' }}>
<Shell>
{files.length > 0 && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
px: 2,
pt: 1.5,
}}
>
{files.map((file, idx) => (
<Box
key={idx}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
bgcolor: 'primary.main',
color: 'white',
pl: 1.25,
pr: 0.5,
py: 0.4,
borderRadius: 999,
fontSize: '0.75rem',
fontWeight: 600,
maxWidth: 220,
}}
>
<Box
component="span"
sx={{
maxWidth: 150,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{file.name}
</Box>
<IconButton
size="small"
onClick={() => removeFile(idx)}
sx={{ color: 'white', p: 0.25 }}
>
<X size={12} />
</IconButton>
</Box>
))}
</Box>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
px: { xs: 1.5, md: 2 },
pt: compact ? 1.25 : 1.75,
pb: 1,
gap: 0.5,
}}
>
<TextArea
ref={textareaRef}
rows={1}
placeholder={t('aiPlaceholder')}
value={input}
onChange={(e) => {
setInput(e.target.value);
autoGrow();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
style={{ fontSize: compact ? '0.95rem' : '1rem' }}
/>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mt: 0.25,
}}
>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<input
type="file"
multiple
hidden
ref={fileInputRef}
onChange={handleFiles}
/>
<Tooltip title="Anhang hinzufügen">
<IconButton
size="small"
onClick={() => fileInputRef.current?.click()}
sx={{ color: 'text.secondary' }}
>
<Paperclip size={17} />
</IconButton>
</Tooltip>
<Tooltip title="Spracheingabe (bald verfügbar)">
<span>
<IconButton
size="small"
disabled
sx={{ color: 'text.secondary' }}
>
<Mic size={17} />
</IconButton>
</span>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
{!compact && (
<Box
sx={{
display: { xs: 'none', sm: 'inline-flex' },
alignItems: 'center',
gap: 0.5,
color: 'text.secondary',
fontSize: '0.72rem',
fontWeight: 500,
}}
>
<Sparkles size={12} />
<span>Gemini 3 Flash</span>
</Box>
)}
<IconButton
onClick={handleSend}
disabled={!canSend}
aria-label="Senden"
sx={{
width: 36,
height: 36,
bgcolor: 'primary.main',
color: 'white',
borderRadius: 2.5,
transition:
'transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease',
boxShadow: 'var(--pp-shadow-brand)',
'&:hover': {
bgcolor: 'primary.dark',
transform: 'translateY(-1px)',
},
'&.Mui-disabled': {
bgcolor: 'action.disabledBackground',
color: 'action.disabled',
boxShadow: 'none',
},
}}
>
{isLoading ? (
<CircularProgress size={16} color="inherit" />
) : (
<Send size={15} />
)}
</IconButton>
</Box>
</Box>
</Box>
</Shell>
</Box>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import { Box, Paper, Typography, TextField, Button } from '@mui/material';
import { MessageSquare, Search } from 'lucide-react';
import { ragService } from '../services/rag-service';
export default function ChatBot() {
const [searchQuery, setSearchQuery] = useState('');
const [chatResponse, setChatResponse] = useState<any>(null);
const handleSearch = async () => {
if (!searchQuery.trim()) return;
console.log(`[PatchPilot] RAG Search gestartet: "${searchQuery}"`);
const res = await ragService.search(searchQuery);
console.log(`[PatchPilot] RAG Antwort erhalten:`, res);
setChatResponse(res);
};
return (
<Paper sx={{ p: 3, mb: 4, background: '#F3EDF7', borderRadius: 3 }}>
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', fontWeight: 'bold' }}>
<MessageSquare size={20} style={{ marginRight: 8 }} /> Reparatur-Assistent (RAG)
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
fullWidth placeholder="Was möchtest du reparieren?"
variant="outlined" size="small"
value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
sx={{ bgcolor: 'white', borderRadius: 1 }}
/>
<Button
variant="contained"
onClick={handleSearch}
startIcon={<Search size={18} />}
sx={{ borderRadius: 2 }}
>
Fragen
</Button>
</Box>
{chatResponse && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'white', borderRadius: 2, borderLeft: '4px solid #6750A4' }}>
<Typography variant="body1">{chatResponse.answer}</Typography>
{chatResponse.sources.length > 0 && (
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: 'gray', fontStyle: 'italic' }}>
Quelle: {chatResponse.sources.join(', ')}
</Typography>
)}
</Box>
)}
</Paper>
);
}
+278
View File
@@ -0,0 +1,278 @@
'use client';
import { useState } from 'react';
import {
Box,
Typography,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Divider,
IconButton,
Button,
Drawer,
useMediaQuery,
Tooltip,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
MessageSquare,
Plus,
Trash2,
Menu as MenuIcon,
Pencil,
Sparkles,
} from 'lucide-react';
import { useChatStore } from '../services/chat-store';
import { useI18n } from '../services/i18n';
const SIDEBAR_WIDTH = 280;
export default function ChatSidebar() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { t } = useI18n();
const {
sessions,
activeSessionId,
setActiveSession,
createSession,
deleteSession,
renameSession,
} = useChatStore();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const handleNew = () => {
createSession('Neue Reparatur');
setOpen(false);
};
const startEdit = (id: string, currentTitle: string) => {
setEditingId(id);
setEditValue(currentTitle);
};
const commitEdit = () => {
if (editingId && editValue.trim()) renameSession(editingId, editValue.trim());
setEditingId(null);
setEditValue('');
};
const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
deleteSession(id);
};
const handleSelect = (id: string) => {
setActiveSession(id);
setOpen(false);
};
const content = (
<Box
sx={{
width: SIDEBAR_WIDTH,
height: '100%',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.paper',
borderRight: { md: '1px solid' },
borderColor: { md: 'divider' },
}}
>
<Box sx={{ p: 2 }}>
<Button
fullWidth
variant="contained"
onClick={handleNew}
startIcon={<Plus size={16} />}
sx={{ py: 1, fontSize: '0.85rem' }}
>
{t('newChat')}
</Button>
</Box>
<Divider />
<Box sx={{ px: 2, pt: 1.75, pb: 1, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Sparkles size={12} />
<Typography
variant="caption"
sx={{
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'text.secondary',
fontSize: '0.68rem',
}}
>
{t('chatHistory')} · {sessions.length}
</Typography>
</Box>
<List sx={{ flexGrow: 1, overflowY: 'auto', px: 1, py: 0 }}>
{sessions.length === 0 && (
<Box sx={{ px: 2, py: 4, textAlign: 'center', color: 'text.secondary' }}>
<MessageSquare size={28} style={{ opacity: 0.35, marginBottom: 8 }} />
<Typography variant="caption">{t('chatNoSessionHint')}</Typography>
</Box>
)}
{sessions.map((session) => {
const selected = activeSessionId === session.id;
const editing = editingId === session.id;
return (
<ListItem
key={session.id}
disablePadding
sx={{
mb: 0.5,
'& .pp-row-actions': { opacity: 0, transition: 'opacity 0.15s' },
'&:hover .pp-row-actions': { opacity: 1 },
}}
secondaryAction={
!editing && (
<Box
className="pp-row-actions"
sx={{
display: 'flex',
gap: 0,
pr: 0.25,
...(selected && { opacity: 1 }),
}}
>
<Tooltip title={t('chatRename')}>
<IconButton
edge="end"
size="small"
onClick={(e) => {
e.stopPropagation();
startEdit(session.id, session.title);
}}
sx={{
color: selected ? 'inherit' : 'text.secondary',
p: 0.5,
}}
>
<Pencil size={13} />
</IconButton>
</Tooltip>
<Tooltip title={t('chatDelete')}>
<IconButton
edge="end"
size="small"
onClick={(e) => handleDelete(e, session.id)}
sx={{
color: selected ? 'inherit' : 'text.secondary',
p: 0.5,
}}
>
<Trash2 size={13} />
</IconButton>
</Tooltip>
</Box>
)
}
>
<ListItemButton
selected={selected}
onClick={() => handleSelect(session.id)}
sx={{
borderRadius: 2,
pr: 7,
py: 0.85,
minHeight: 44,
'&.Mui-selected': {
bgcolor: 'primary.main',
color: 'white',
'&:hover': { bgcolor: 'primary.dark' },
'& .MuiListItemIcon-root': { color: 'white' },
},
}}
>
<ListItemIcon
sx={{
minWidth: 28,
color: selected ? 'white' : 'text.secondary',
}}
>
<MessageSquare size={14} />
</ListItemIcon>
{editing ? (
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') commitEdit();
if (e.key === 'Escape') setEditingId(null);
}}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: 'inherit',
fontFamily: 'inherit',
fontSize: '0.84rem',
fontWeight: 600,
}}
/>
) : (
<ListItemText
primary={session.title}
primaryTypographyProps={{
variant: 'body2',
noWrap: true,
fontWeight: selected ? 700 : 500,
fontSize: '0.84rem',
}}
/>
)}
</ListItemButton>
</ListItem>
);
})}
</List>
</Box>
);
if (isMobile) {
return (
<>
<Box sx={{ position: 'absolute', top: 12, left: 12, zIndex: 4 }}>
<IconButton
onClick={() => setOpen(true)}
sx={{
width: 36,
height: 36,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
boxShadow: 'var(--pp-shadow-soft)',
}}
aria-label="Chat-Verlauf"
>
<MenuIcon size={16} />
</IconButton>
</Box>
<Drawer
anchor="left"
open={open}
onClose={() => setOpen(false)}
PaperProps={{ sx: { width: SIDEBAR_WIDTH } }}
>
{content}
</Drawer>
</>
);
}
return content;
}
+707
View File
@@ -0,0 +1,707 @@
'use client';
import { useState, useRef, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Paper,
Avatar,
IconButton,
Chip,
useMediaQuery,
Tooltip,
Fab,
Zoom,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { motion, AnimatePresence } from 'framer-motion';
import {
User,
Bot,
Sparkles,
ChevronDown,
RotateCcw,
Copy,
Check,
AlertCircle,
} from 'lucide-react';
import Logo from './Logo';
import { useChatStore, type MessageAttachment } from '../services/chat-store';
import { useAuthStore } from '../services/auth-store';
import { useToolsStore } from '../services/tools-store';
import { useReservationStore } from '../services/reservation-store';
import { MOCK_USERS } from '../services/mock-data';
import { fileToDataUrl } from '../services/file-utils';
import AIInput from './AIInput';
import MarkdownMessage from './MarkdownMessage';
interface ChatApiResponse {
content: string;
mode?: 'live' | 'mock' | 'mock-fallback';
matchedTools?: unknown[];
sources?: string[];
error?: string;
}
const SUGGESTED_PROMPTS = [
{ icon: '🔧', text: 'Wie repariere ich ein tropfendes Fahrrad-Ventil?' },
{ icon: '🪛', text: 'Welches Werkzeug brauche ich zum Bohren in Beton?' },
{ icon: '🖨️', text: 'Tipps für meinen ersten 3D-Druck mit PLA' },
{ icon: '📅', text: 'Wann ist das Repair Café geöffnet?' },
];
function formatTime(ts: number) {
return new Date(ts).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
export default function ChatWindow() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { sessions, activeSessionId, addMessage, createSession } = useChatStore();
const [isLoading, setIsLoading] = useState(false);
const [lastMode, setLastMode] = useState<ChatApiResponse['mode'] | null>(null);
const [showScrollDown, setShowScrollDown] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId]
);
useEffect(() => {
if (!activeSession || activeSession.messages.length === 0) return;
const last = activeSession.messages[activeSession.messages.length - 1];
if (last.role === 'user' && !isLoading) {
void sendToApi(activeSession.id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSession?.messages.length]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [activeSession?.messages.length, isLoading]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollDown(distance > 200);
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [activeSessionId]);
const sendToApi = async (sessionId: string) => {
const current = useChatStore.getState().sessions.find((s) => s.id === sessionId);
if (!current) return;
setIsLoading(true);
try {
const history = current.messages.map((m) => ({
role: m.role,
content: m.content,
attachments: m.attachments,
}));
const user = useAuthStore.getState().user;
const tools = useToolsStore.getState().tools;
const reservations = useReservationStore.getState().reservations;
const context = {
userRole: user?.role,
username: user?.username,
tools,
reservations,
users: MOCK_USERS.map((u) => ({ id: u.id, username: u.username, role: u.role })),
};
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: history, context }),
});
const data = (await response.json()) as ChatApiResponse;
if (data.error || !data.content) throw new Error(data.error || 'Empty response');
setLastMode(data.mode ?? null);
addMessage(sessionId, { role: 'assistant', content: data.content });
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unbekannter Fehler';
addMessage(sessionId, {
role: 'assistant',
content: `⚠️ Es gab ein Problem bei der Verbindung: ${msg}`,
});
} finally {
setIsLoading(false);
}
};
const handleSend = async (content: string, files?: File[]) => {
const hasFiles = Boolean(files && files.length > 0);
if (!content.trim() && !hasFiles) return;
let targetId = activeSessionId;
if (!targetId) targetId = createSession(content.slice(0, 30) || 'Bild-Analyse');
let attachments: MessageAttachment[] | undefined;
if (hasFiles) {
const processed = await Promise.all(
files!.map(async (file) => {
const { dataUrl, type, size } = await fileToDataUrl(file);
return { name: file.name, type, dataUrl, size };
})
);
attachments = processed;
}
addMessage(targetId, {
role: 'user',
content: content.trim(),
attachments,
});
};
const handleSuggested = (text: string) => handleSend(text);
const handleCopy = async (id: string, text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId((cur) => (cur === id ? null : cur)), 1500);
} catch {
/* ignore */
}
};
const handleRetry = () => {
if (!activeSession || activeSession.messages.length === 0 || !activeSessionId) return;
const lastUser = [...activeSession.messages].reverse().find((m) => m.role === 'user');
if (lastUser) void sendToApi(activeSessionId);
};
const scrollToBottom = () =>
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
if (!activeSession) return <EmptyState onPick={handleSuggested} />;
const isEmpty = activeSession.messages.length === 0;
return (
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
height: '100%',
bgcolor: 'background.default',
position: 'relative',
minWidth: 0,
}}
>
{/* Header */}
<Box
sx={{
px: { xs: 2, md: 3 },
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
alignItems: 'center',
gap: 1.5,
minHeight: 56,
flexShrink: 0,
}}
>
<Box
sx={{
width: 34,
height: 34,
borderRadius: 2.5,
background: 'linear-gradient(135deg, #FF6B00, #E55F00)',
color: 'white',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
boxShadow: 'var(--pp-shadow-brand)',
}}
>
<Bot size={16} />
</Box>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography
variant="subtitle1"
sx={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.95rem',
lineHeight: 1.2,
}}
>
{activeSession.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
{activeSession.messages.length} Nachrichten · PatchPilot-KI
</Typography>
</Box>
<ModeBadge mode={lastMode} />
</Box>
{/* Messages */}
<Box
ref={scrollRef}
sx={{
flexGrow: 1,
overflowY: 'auto',
px: { xs: 2, md: 4 },
py: { xs: 2.5, md: 3.5 },
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{isEmpty && (
<Box sx={{ m: 'auto', textAlign: 'center', maxWidth: 480, py: 4 }}>
<Box
sx={{
width: 52,
height: 52,
mx: 'auto',
borderRadius: 3,
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, #FF6B00, #E55F00)',
color: 'white',
mb: 2,
boxShadow: 'var(--pp-shadow-brand)',
}}
>
<Sparkles size={24} />
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
Worüber sollen wir reden?
</Typography>
<Typography variant="body2" color="text.secondary">
Wähle einen Vorschlag unten oder schreibe deine Frage.
</Typography>
</Box>
)}
<AnimatePresence initial={false}>
{activeSession.messages.map((msg) => {
const isUser = msg.role === 'user';
return (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<Box
sx={{
display: 'flex',
gap: { xs: 1.25, md: 1.5 },
flexDirection: isUser ? 'row-reverse' : 'row',
alignItems: 'flex-start',
}}
>
<Avatar
sx={{
bgcolor: isUser ? 'primary.main' : 'secondary.main',
color: 'white',
width: 30,
height: 30,
mt: 0.5,
flexShrink: 0,
}}
>
{isUser ? <User size={15} /> : <Bot size={15} />}
</Avatar>
<Box
sx={{
maxWidth: { xs: '85%', md: '75%' },
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
gap: 0.5,
}}
>
<Paper
elevation={0}
sx={{
px: { xs: 1.75, md: 2 },
py: { xs: 1.25, md: 1.5 },
borderRadius: 2.5,
borderTopRightRadius: isUser ? 6 : undefined,
borderTopLeftRadius: !isUser ? 6 : undefined,
bgcolor: isUser ? 'primary.main' : 'background.paper',
color: isUser ? 'white' : 'text.primary',
border: isUser ? 'none' : '1px solid',
borderColor: 'divider',
wordBreak: 'break-word',
}}
>
{msg.attachments && msg.attachments.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns:
msg.attachments.length === 1
? '1fr'
: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 0.75,
mb: msg.content ? 1 : 0,
}}
>
{msg.attachments.map((att, i) =>
att.type.startsWith('image/') ? (
<Box
key={i}
component="a"
href={att.dataUrl}
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'block',
borderRadius: 1.5,
overflow: 'hidden',
border: '1px solid',
borderColor: isUser ? 'rgba(255,255,255,0.25)' : 'divider',
maxWidth: msg.attachments!.length === 1 ? 280 : 'unset',
}}
>
<Box
component="img"
src={att.dataUrl}
alt={att.name}
sx={{
width: '100%',
height: 'auto',
display: 'block',
maxHeight: 260,
objectFit: 'cover',
}}
/>
</Box>
) : (
<Box
key={i}
sx={{
p: 1,
borderRadius: 1.5,
border: '1px solid',
borderColor: isUser ? 'rgba(255,255,255,0.25)' : 'divider',
fontSize: '0.75rem',
fontWeight: 600,
bgcolor: isUser ? 'rgba(255,255,255,0.1)' : 'action.hover',
display: 'flex',
alignItems: 'center',
gap: 0.75,
}}
>
📎 {att.name}
</Box>
)
)}
</Box>
)}
{msg.content &&
(isUser ? (
<Typography
variant="body1"
sx={{ fontSize: '0.95rem', lineHeight: 1.55, whiteSpace: 'pre-wrap' }}
>
{msg.content}
</Typography>
) : (
<MarkdownMessage content={msg.content} />
))}
</Paper>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 0.25,
opacity: 0.7,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{formatTime(msg.timestamp)}
</Typography>
{!isUser && (
<Tooltip title={copiedId === msg.id ? 'Kopiert!' : 'Kopieren'}>
<IconButton
size="small"
onClick={() => handleCopy(msg.id, msg.content)}
sx={{ p: 0.25, color: 'text.secondary' }}
>
{copiedId === msg.id ? <Check size={12} /> : <Copy size={12} />}
</IconButton>
</Tooltip>
)}
</Box>
</Box>
</Box>
</motion.div>
);
})}
</AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<Avatar
sx={{
bgcolor: 'secondary.main',
color: 'white',
width: 30,
height: 30,
mt: 0.5,
flexShrink: 0,
}}
>
<Bot size={15} />
</Avatar>
<Paper
elevation={0}
sx={{
px: 2,
py: 1.5,
borderRadius: 2.5,
borderTopLeftRadius: 6,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'inline-flex',
alignItems: 'center',
color: 'text.secondary',
}}
>
<Box component="span" className="pp-typing">
<span /> <span /> <span />
</Box>
</Paper>
</Box>
</motion.div>
)}
<div ref={bottomRef} />
</Box>
{/* Scroll-to-bottom */}
<Zoom in={showScrollDown}>
<Fab
size="small"
color="primary"
onClick={scrollToBottom}
sx={{
position: 'absolute',
bottom: { xs: 140, md: 150 },
right: { xs: 16, md: 28 },
boxShadow: 'var(--pp-shadow-brand)',
}}
aria-label="Zum Ende scrollen"
>
<ChevronDown size={18} />
</Fab>
</Zoom>
{/* Footer */}
<Box
sx={{
px: { xs: 2, md: 4 },
pt: 1.5,
pb: { xs: 2, md: 2.5 },
borderTop: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
flexShrink: 0,
}}
>
{isEmpty && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
mb: 1.5,
maxWidth: 760,
mx: 'auto',
}}
>
{SUGGESTED_PROMPTS.map((p) => (
<Chip
key={p.text}
onClick={() => handleSuggested(p.text)}
clickable
variant="outlined"
label={`${p.icon} ${p.text}`}
sx={{
borderRadius: 2,
height: 32,
fontSize: '0.78rem',
borderStyle: 'dashed',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'action.hover',
borderStyle: 'solid',
},
}}
/>
))}
</Box>
)}
<Box
sx={{
display: 'flex',
alignItems: 'flex-end',
gap: 1,
maxWidth: 760,
mx: 'auto',
}}
>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<AIInput onSend={handleSend} isLoading={isLoading} compact />
</Box>
{!isMobile && (
<Tooltip title="Letzte Anfrage erneut senden">
<span>
<IconButton
onClick={handleRetry}
disabled={isLoading || activeSession.messages.length === 0}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
width: 40,
height: 40,
}}
>
<RotateCcw size={16} />
</IconButton>
</span>
</Tooltip>
)}
</Box>
</Box>
</Box>
);
}
function EmptyState({ onPick }: { onPick: (text: string) => void }) {
return (
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: { xs: 3, md: 6 },
textAlign: 'center',
bgcolor: 'background.default',
minWidth: 0,
}}
>
<Box sx={{ mb: 2.5 }}>
<Logo size={72} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 800, mb: 1, letterSpacing: '-0.01em' }}>
Womit darf ich helfen?
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 460, mb: 4, lineHeight: 1.55 }}>
Frag mich nach Werkzeugen, Reparatur-Tipps oder beschreibe dein Problem.
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
gap: 1.5,
width: '100%',
maxWidth: 640,
}}
>
{SUGGESTED_PROMPTS.map((p) => (
<Paper
key={p.text}
variant="outlined"
onClick={() => onPick(p.text)}
sx={{
p: 2,
cursor: 'pointer',
textAlign: 'left',
borderRadius: 2.5,
transition: 'all 0.2s',
borderStyle: 'dashed',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'action.hover',
transform: 'translateY(-2px)',
borderStyle: 'solid',
},
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.88rem' }}>
{p.icon}&nbsp;&nbsp;{p.text}
</Typography>
</Paper>
))}
</Box>
</Box>
);
}
function ModeBadge({ mode }: { mode: ChatApiResponse['mode'] | null }) {
if (!mode) return null;
if (mode === 'live') {
return (
<Chip
size="small"
label="Live"
sx={{
bgcolor: 'success.main',
color: 'white',
fontWeight: 700,
fontSize: '0.65rem',
height: 22,
letterSpacing: '0.05em',
}}
/>
);
}
if (mode === 'mock-fallback') {
return (
<Tooltip title="Live-API nicht erreichbar, Fallback aktiv">
<Chip
size="small"
icon={<AlertCircle size={11} />}
label="Fallback"
sx={{
bgcolor: 'warning.main',
color: 'white',
fontWeight: 700,
fontSize: '0.65rem',
height: 22,
}}
/>
</Tooltip>
);
}
return (
<Chip
size="small"
label="Demo"
variant="outlined"
sx={{ fontWeight: 700, fontSize: '0.65rem', height: 22 }}
/>
);
}
+272
View File
@@ -0,0 +1,272 @@
'use client';
import { Box, Link as MuiLink, Typography, Paper, useTheme } from '@mui/material';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface MarkdownMessageProps {
content: string;
inverse?: boolean;
}
export default function MarkdownMessage({ content, inverse }: MarkdownMessageProps) {
const theme = useTheme();
const codeBg = inverse
? 'rgba(255,255,255,0.18)'
: theme.palette.mode === 'light'
? '#F3F4F6'
: 'rgba(255,255,255,0.06)';
const codeColor = inverse ? 'white' : theme.palette.text.primary;
const borderColor = inverse ? 'rgba(255,255,255,0.25)' : theme.palette.divider;
const linkColor = inverse ? 'rgba(255,255,255,0.95)' : theme.palette.primary.main;
return (
<Box
sx={{
fontSize: '0.95rem',
lineHeight: 1.6,
color: 'inherit',
'& > *:first-of-type': { mt: 0 },
'& > *:last-of-type': { mb: 0 },
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<Typography
component="h1"
sx={{ fontSize: '1.25rem', fontWeight: 800, mt: 2, mb: 1, letterSpacing: '-0.01em' }}
>
{children}
</Typography>
),
h2: ({ children }) => (
<Typography
component="h2"
sx={{ fontSize: '1.1rem', fontWeight: 800, mt: 1.75, mb: 0.75, letterSpacing: '-0.005em' }}
>
{children}
</Typography>
),
h3: ({ children }) => (
<Typography
component="h3"
sx={{ fontSize: '1rem', fontWeight: 700, mt: 1.5, mb: 0.5 }}
>
{children}
</Typography>
),
h4: ({ children }) => (
<Typography
component="h4"
sx={{ fontSize: '0.95rem', fontWeight: 700, mt: 1.25, mb: 0.5 }}
>
{children}
</Typography>
),
p: ({ children }) => (
<Box component="p" sx={{ my: 0.75, '&:first-of-type': { mt: 0 } }}>
{children}
</Box>
),
strong: ({ children }) => (
<Box component="strong" sx={{ fontWeight: 700 }}>
{children}
</Box>
),
em: ({ children }) => (
<Box component="em" sx={{ fontStyle: 'italic' }}>
{children}
</Box>
),
ul: ({ children }) => (
<Box
component="ul"
sx={{
pl: 2.5,
my: 0.75,
'& li': { mb: 0.35 },
'& li::marker': {
color: inverse ? 'rgba(255,255,255,0.7)' : theme.palette.primary.main,
},
}}
>
{children}
</Box>
),
ol: ({ children }) => (
<Box
component="ol"
sx={{
pl: 2.5,
my: 0.75,
'& li': { mb: 0.35 },
'& li::marker': {
color: inverse ? 'rgba(255,255,255,0.7)' : theme.palette.primary.main,
fontWeight: 700,
},
}}
>
{children}
</Box>
),
li: ({ children }) => <Box component="li">{children}</Box>,
blockquote: ({ children }) => (
<Box
sx={{
borderLeft: '3px solid',
borderColor: inverse ? 'rgba(255,255,255,0.4)' : 'primary.main',
pl: 1.5,
py: 0.25,
my: 1,
color: inverse ? 'rgba(255,255,255,0.9)' : 'text.secondary',
fontStyle: 'italic',
}}
>
{children}
</Box>
),
a: ({ href, children }) => (
<MuiLink
href={href}
target="_blank"
rel="noopener noreferrer"
sx={{
color: linkColor,
textDecoration: 'underline',
textDecorationColor: 'currentColor',
textUnderlineOffset: 2,
fontWeight: 600,
'&:hover': { textDecoration: 'underline' },
}}
>
{children}
</MuiLink>
),
code: ({ inline, children }: any) => {
if (inline) {
return (
<Box
component="code"
sx={{
fontFamily:
'ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: '0.85em',
bgcolor: codeBg,
color: codeColor,
px: 0.6,
py: 0.15,
borderRadius: 1,
border: '1px solid',
borderColor,
}}
>
{children}
</Box>
);
}
return (
<Paper
variant="outlined"
component="pre"
sx={{
fontFamily:
'ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: '0.82rem',
lineHeight: 1.5,
my: 1,
p: 1.5,
borderRadius: 2,
overflowX: 'auto',
bgcolor: codeBg,
color: codeColor,
borderColor,
border: '1px solid',
}}
>
<code>{children}</code>
</Paper>
);
},
hr: () => (
<Box
component="hr"
sx={{
border: 'none',
borderTop: '1px solid',
borderColor,
my: 1.5,
}}
/>
),
table: ({ children }) => (
<Box
sx={{
overflowX: 'auto',
my: 1,
borderRadius: 2,
border: '1px solid',
borderColor,
}}
>
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.85rem',
}}
>
{children}
</Box>
</Box>
),
thead: ({ children }) => (
<Box
component="thead"
sx={{
bgcolor: inverse ? 'rgba(255,255,255,0.1)' : 'action.hover',
fontWeight: 700,
}}
>
{children}
</Box>
),
th: ({ children }) => (
<Box
component="th"
sx={{
textAlign: 'left',
px: 1.25,
py: 0.75,
borderBottom: '1px solid',
borderColor,
fontWeight: 700,
}}
>
{children}
</Box>
),
td: ({ children }) => (
<Box
component="td"
sx={{
px: 1.25,
py: 0.65,
borderBottom: '1px solid',
borderColor,
}}
>
{children}
</Box>
),
}}
>
{content}
</ReactMarkdown>
</Box>
);
}
+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.";
}
};