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
+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>
);
}