feat: add chat assistant with role-aware prompt and vision support
This commit is contained in:
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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} {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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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