feat: F024 global AI assistant — contextual decision intelligence drawer
Wires the existing "AI Assistent" button in the TopBar and adds a 420px slide-in drawer with context-aware suggestions, message thread, loading animation, and action cards requiring manual confirmation. Template-based mock service returns German-language answers keyed by route + question keywords — no real LLM calls, no invented facts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { AssistantAction } from '../../domain/assistant'
|
||||
|
||||
interface Props {
|
||||
actions: AssistantAction[]
|
||||
onExecute?: (action: AssistantAction) => void
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
NAVIGATE: '#1e3a5f',
|
||||
OPEN_REVIEW: '#7c3aed',
|
||||
ADD_TO_SHORTLIST: '#1a7a4a',
|
||||
REQUEST_DATA: '#d97706',
|
||||
SEND_TO_REVIEW: '#ea580c',
|
||||
}
|
||||
|
||||
export function AssistantActionCards({ actions, onExecute }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleExecute = (action: AssistantAction) => {
|
||||
if (action.actionType === 'NAVIGATE' && action.payload?.path) {
|
||||
navigate(action.payload.path as string)
|
||||
} else if (action.actionType === 'OPEN_REVIEW') {
|
||||
navigate('/ops/review-queue')
|
||||
}
|
||||
onExecute?.(action)
|
||||
}
|
||||
|
||||
if (!actions.length) return null
|
||||
|
||||
return (
|
||||
<Box sx={{ px: 2, pt: 0.75, pb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.625, fontSize: '0.6rem' }}>
|
||||
Vorgeschlagene Aktionen
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{actions.map(action => {
|
||||
const color = ACTION_COLORS[action.actionType] ?? '#64748b'
|
||||
return (
|
||||
<Box
|
||||
key={action.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1.25,
|
||||
py: 0.875,
|
||||
borderRadius: 1.5,
|
||||
border: `1px solid ${color}30`,
|
||||
bgcolor: `${color}08`,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color, fontSize: '0.75rem', display: 'block' }}>
|
||||
{action.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{action.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
endIcon={<ArrowRight size={12} />}
|
||||
onClick={() => handleExecute(action)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.7rem',
|
||||
color,
|
||||
borderColor: `${color}60`,
|
||||
flexShrink: 0,
|
||||
ml: 1,
|
||||
py: 0.4,
|
||||
'&:hover': { borderColor: color, bgcolor: `${color}12` },
|
||||
}}
|
||||
>
|
||||
Ausführen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import type { AssistantContext } from '../../domain/assistant'
|
||||
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
'/supply/dashboard': 'Übersicht',
|
||||
'/supply/properties': 'Meine Objekte',
|
||||
'/supply/match-center': 'Eingehende Bedarfe',
|
||||
'/supply/data-quality': 'Datenpflege',
|
||||
'/supply/future-availability':'Marktchancen',
|
||||
'/demand/ai-search': 'Flächensuche',
|
||||
'/demand/results': 'Ergebnisse',
|
||||
'/demand/compare': 'Vergleich',
|
||||
'/demand/shortlists': 'Shortlists',
|
||||
'/ops/review-queue': 'Review Queue',
|
||||
'/ops/ai-monitoring': 'AI Monitoring',
|
||||
'/ops/governance': 'Governance',
|
||||
}
|
||||
|
||||
function resolvePageLabel(route: string): string {
|
||||
for (const [path, label] of Object.entries(PAGE_LABELS)) {
|
||||
if (route.startsWith(path)) return label
|
||||
}
|
||||
return route.split('/').filter(Boolean).pop()?.replace(/-/g, ' ') ?? 'Seite'
|
||||
}
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
PROPERTY: 'Objekt', NEED: 'Gesuch', MATCH: 'Match',
|
||||
SIGNAL: 'Signal', AI_OUTPUT: 'AI-Output',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
context: AssistantContext
|
||||
}
|
||||
|
||||
export function AssistantContextSummary({ context }: Props) {
|
||||
const pageLabel = resolvePageLabel(context.currentRoute)
|
||||
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={pageLabel}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#e0f2fe', color: '#075985', fontWeight: 600, fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
{context.selectedEntityType && context.selectedEntityId && (
|
||||
<Chip
|
||||
label={`${ENTITY_LABELS[context.selectedEntityType] ?? context.selectedEntityType}: ${context.selectedEntityId}`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#f1f5f9', color: '#475569', fontWeight: 500, fontSize: '0.65rem', height: 20, fontFamily: 'monospace' }}
|
||||
/>
|
||||
)}
|
||||
{context.visibleScores?.quality !== undefined && (
|
||||
<Chip
|
||||
label={`Qualität: ${context.visibleScores.quality}%`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: context.visibleScores.quality >= 70 ? '#dcfce7' : '#fef3c7',
|
||||
color: context.visibleScores.quality >= 70 ? '#166534' : '#92400e',
|
||||
fontWeight: 600, fontSize: '0.65rem', height: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{context.visibleScores?.matchScore !== undefined && (
|
||||
<Chip
|
||||
label={`Match: ${context.visibleScores.matchScore}%`}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#ede9fe', color: '#5b21b6', fontWeight: 600, fontSize: '0.65rem', height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{context.visibleMissingData && context.visibleMissingData.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontSize: '0.65rem' }}>
|
||||
Fehlende Daten: {context.visibleMissingData.slice(0, 3).join(', ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Alert, Box, Button } from '@mui/material'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
error: string
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export function AssistantErrorState({ error, onRetry }: Props) {
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ '& .MuiAlert-message': { fontSize: '0.8rem' } }}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button size="small" onClick={onRetry} startIcon={<RefreshCw size={12} />} sx={{ textTransform: 'none', fontSize: '0.75rem' }}>
|
||||
Erneut
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
|
||||
export function AssistantLoadingState() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1, px: 2, py: 1.5, alignItems: 'flex-start' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#4f46e5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'white', fontWeight: 700, fontSize: '0.625rem' }}>AI</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: '#f1f5f9',
|
||||
borderRadius: '0 8px 8px 8px',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map(i => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#94a3b8',
|
||||
animation: 'bounce 1.2s ease-in-out infinite',
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
'@keyframes bounce': {
|
||||
'0%, 80%, 100%': { transform: 'scale(0.8)', opacity: 0.5 },
|
||||
'40%': { transform: 'scale(1.2)', opacity: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { AssistantActionCards } from './AssistantActionCards'
|
||||
import type { AssistantMessage } from '../../domain/assistant'
|
||||
|
||||
function MessageBubble({ message }: { message: AssistantMessage }) {
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: isUser ? 'row-reverse' : 'row', gap: 1, px: 2, py: 0.75, alignItems: 'flex-start' }}>
|
||||
{!isUser && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, mt: 0.25,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'white', fontWeight: 700, fontSize: '0.625rem' }}>AI</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ maxWidth: '80%', minWidth: 0 }}>
|
||||
{/* Bubble */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
|
||||
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9',
|
||||
color: isUser ? 'white' : '#1e293b',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'inherit',
|
||||
'& strong': { fontWeight: 700 },
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: message.content
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br/>'),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Metadata */}
|
||||
{!isUser && (message.confidence !== undefined || (message.sources && message.sources.length > 0)) && (
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{message.confidence !== undefined && (
|
||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8' }}>
|
||||
Konfidenz: {Math.round(message.confidence * 100)}%
|
||||
</Typography>
|
||||
)}
|
||||
{message.sources?.map(s => (
|
||||
<Typography key={s} variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8', bgcolor: '#f8fafc', px: 0.5, py: 0.125, borderRadius: 0.5, border: '1px solid #e2e8f0' }}>
|
||||
{s}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.6rem', color: '#cbd5e1', mt: 0.25, textAlign: isUser ? 'right' : 'left' }}>
|
||||
{new Date(message.createdAt).toLocaleTimeString('de-CH', { timeStyle: 'short' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: AssistantMessage[]
|
||||
}
|
||||
|
||||
export function AssistantMessageList({ messages }: Props) {
|
||||
return (
|
||||
<Box>
|
||||
{messages.map((msg) => (
|
||||
<Box key={msg.id}>
|
||||
<MessageBubble message={msg} />
|
||||
{msg.role === 'assistant' && msg.actions && msg.actions.length > 0 && (
|
||||
<AssistantActionCards actions={msg.actions} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import type { SuggestedQuestion } from '../../domain/assistant'
|
||||
|
||||
interface Props {
|
||||
suggestions: SuggestedQuestion[]
|
||||
onSelect: (question: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Match: '#4f46e5',
|
||||
Datenqualität: '#d97706',
|
||||
Priorisierung: '#1e3a5f',
|
||||
Empfehlung: '#1a7a4a',
|
||||
Risiko: '#c0392b',
|
||||
Tradeoffs: '#ea580c',
|
||||
Strategie: '#0891b2',
|
||||
Analyse: '#7c3aed',
|
||||
Erklärung: '#0891b2',
|
||||
Evidenz: '#64748b',
|
||||
Review: '#7c3aed',
|
||||
Konfidenz: '#d97706',
|
||||
Fehler: '#c0392b',
|
||||
Fehleranalyse: '#ea580c',
|
||||
Eskalation: '#ea580c',
|
||||
Prozess: '#64748b',
|
||||
Kosten: '#1a7a4a',
|
||||
Impact: '#d97706',
|
||||
Optimierung: '#1a7a4a',
|
||||
Aktion: '#1e3a5f',
|
||||
Überblick: '#64748b',
|
||||
Ranking: '#4f46e5',
|
||||
}
|
||||
|
||||
export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }: Props) {
|
||||
if (suggestions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1.25 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Vorschläge
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
|
||||
{suggestions.map(s => {
|
||||
const catColor = CATEGORY_COLORS[s.category] ?? '#64748b'
|
||||
return (
|
||||
<Box
|
||||
key={s.id}
|
||||
onClick={() => !disabled && onSelect(s.question)}
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 0.875,
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
bgcolor: 'white',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': disabled ? {} : { bgcolor: '#f8fafc', borderColor: '#cbd5e1' },
|
||||
transition: 'all 0.1s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 28,
|
||||
borderRadius: 2,
|
||||
bgcolor: catColor,
|
||||
flexShrink: 0,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.8rem', color: '#334155', lineHeight: 1.4 }}>
|
||||
{s.question}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={s.category}
|
||||
size="small"
|
||||
sx={{ height: 16, fontSize: '0.6rem', bgcolor: `${catColor}14`, color: catColor, fontWeight: 600, ml: 0.5, verticalAlign: 'middle' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Box, IconButton, Tooltip } from '@mui/material'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { useAssistantStore } from '../../stores/assistantStore'
|
||||
|
||||
export function GlobalAIAssistantButton() {
|
||||
const { isOpen, open } = useAssistantStore()
|
||||
|
||||
return (
|
||||
<Tooltip title="AI Assistent öffnen" placement="left">
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 1250,
|
||||
display: isOpen ? 'none' : 'flex',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={open}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
bgcolor: '#4f46e5',
|
||||
color: 'white',
|
||||
boxShadow: '0 4px 16px rgba(79,70,229,0.4)',
|
||||
'&:hover': { bgcolor: '#4338ca' },
|
||||
}}
|
||||
>
|
||||
<Sparkles size={20} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Box, Divider, Drawer, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { RotateCcw, Send, Sparkles, X } from 'lucide-react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { useAssistantStore } from '../../stores/assistantStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { aiAssistantService } from '../../services/aiAssistantService'
|
||||
import { AssistantContextSummary } from './AssistantContextSummary'
|
||||
import { AssistantMessageList } from './AssistantMessageList'
|
||||
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
|
||||
import { AssistantLoadingState } from './AssistantLoadingState'
|
||||
import { AssistantErrorState } from './AssistantErrorState'
|
||||
import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant'
|
||||
import type { WorkspaceType } from '../../domain/enums'
|
||||
|
||||
function resolveWorkspace(pathname: string): WorkspaceType | null {
|
||||
if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType
|
||||
if (pathname.startsWith('/demand')) return 'DEMAND' as WorkspaceType
|
||||
if (pathname.startsWith('/ops')) return 'OPERATIONS' as WorkspaceType
|
||||
return null
|
||||
}
|
||||
|
||||
export function GlobalAIAssistantDrawer() {
|
||||
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
|
||||
useAssistantStore()
|
||||
|
||||
const { currentUser } = useSessionStore()
|
||||
const location = useLocation()
|
||||
|
||||
const [suggestions, setSuggestions] = useState<SuggestedQuestion[]>([])
|
||||
const [inputText, setInputText] = useState('')
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Build context from route when drawer opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const ctx: AssistantContext = {
|
||||
currentRoute: location.pathname,
|
||||
workspace: resolveWorkspace(location.pathname),
|
||||
userRole: currentUser?.role ?? 'VIEWER',
|
||||
organizationId: currentUser?.organizationId ?? '',
|
||||
}
|
||||
setContext(ctx)
|
||||
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
|
||||
}, [isOpen, location.pathname])
|
||||
|
||||
// Refresh suggestions when route changes while open
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const ctx: AssistantContext = {
|
||||
currentRoute: location.pathname,
|
||||
workspace: resolveWorkspace(location.pathname),
|
||||
userRole: currentUser?.role ?? 'VIEWER',
|
||||
organizationId: currentUser?.organizationId ?? '',
|
||||
}
|
||||
setContext(ctx)
|
||||
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
|
||||
}, [location.pathname])
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, isLoading])
|
||||
|
||||
const handleQuestion = useCallback(async (question: string) => {
|
||||
if (!question.trim() || isLoading) return
|
||||
setInputText('')
|
||||
setError(null)
|
||||
|
||||
const userMsg = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user' as const,
|
||||
content: question.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
addMessage(userMsg)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const ctx = context ?? {
|
||||
currentRoute: location.pathname,
|
||||
workspace: resolveWorkspace(location.pathname),
|
||||
userRole: currentUser?.role ?? 'VIEWER',
|
||||
organizationId: currentUser?.organizationId ?? '',
|
||||
}
|
||||
const answer = await aiAssistantService.answerQuestion(ctx, question)
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
createdAt: new Date().toISOString(),
|
||||
...answer,
|
||||
})
|
||||
} catch {
|
||||
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleQuestion(inputText)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
clearConversation()
|
||||
setSuggestions([])
|
||||
if (context) {
|
||||
aiAssistantService.getSuggestions(context).then(setSuggestions)
|
||||
}
|
||||
}
|
||||
|
||||
const showSuggestions = suggestions.length > 0 && messages.length === 0
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={isOpen}
|
||||
onClose={close}
|
||||
variant="temporary"
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: 420,
|
||||
top: '56px',
|
||||
height: 'calc(100% - 56px)',
|
||||
boxShadow: '-4px 0 24px rgba(0,0,0,0.12)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Sparkles size={14} color="white" />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.2 }}>
|
||||
AI Assistent
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
Kontextbasierte Entscheidungsunterstützung
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="Gespräch zurücksetzen">
|
||||
<IconButton size="small" onClick={handleClear} disabled={messages.length === 0} sx={{ color: '#94a3b8' }}>
|
||||
<RotateCcw size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={close} sx={{ color: '#94a3b8' }}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Context summary */}
|
||||
{context && <AssistantContextSummary context={context} />}
|
||||
|
||||
{/* Scrollable body */}
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{/* Welcome message */}
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 2, p: 1.5, border: '1px solid #e2e8f0' }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: '#334155', lineHeight: 1.6 }}>
|
||||
Ich helfe Ihnen mit kontextbezogenen Fragen zu dieser Seite. Meine Antworten basieren auf strukturierten Daten — keine erfundenen Fakten.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{showSuggestions && (
|
||||
<>
|
||||
<AssistantPromptSuggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={handleQuestion}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Divider sx={{ mx: 2, my: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{messages.length > 0 && (
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<AssistantMessageList messages={messages} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Inline suggestions after messages */}
|
||||
{messages.length > 0 && suggestions.length > 0 && !isLoading && (
|
||||
<>
|
||||
<Divider sx={{ mx: 2, my: 0.5 }} />
|
||||
<AssistantPromptSuggestions
|
||||
suggestions={suggestions.slice(0, 2)}
|
||||
onSelect={handleQuestion}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && <AssistantLoadingState />}
|
||||
|
||||
{/* Error */}
|
||||
{error && <AssistantErrorState error={error} onRetry={() => setError(null)} />}
|
||||
</Box>
|
||||
|
||||
{/* Input area */}
|
||||
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
|
||||
<TextField
|
||||
multiline
|
||||
maxRows={4}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Frage stellen…"
|
||||
value={inputText}
|
||||
onChange={e => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': { fontSize: '0.8125rem', borderRadius: 2 },
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="Senden (Enter)">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={() => handleQuestion(inputText)}
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
sx={{
|
||||
bgcolor: '#4f46e5',
|
||||
color: 'white',
|
||||
flexShrink: 0,
|
||||
'&:hover': { bgcolor: '#4338ca' },
|
||||
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontSize: '0.65rem', textAlign: 'center' }}>
|
||||
Antworten sind datenbasiert — Aktionen erfordern manuelle Bestätigung
|
||||
</Typography>
|
||||
</Box>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { GlobalAIAssistantButton } from './GlobalAIAssistantButton'
|
||||
export { GlobalAIAssistantDrawer } from './GlobalAIAssistantDrawer'
|
||||
export { AssistantMessageList } from './AssistantMessageList'
|
||||
export { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
|
||||
export { AssistantContextSummary } from './AssistantContextSummary'
|
||||
export { AssistantActionCards } from './AssistantActionCards'
|
||||
export { AssistantLoadingState } from './AssistantLoadingState'
|
||||
export { AssistantErrorState } from './AssistantErrorState'
|
||||
Reference in New Issue
Block a user