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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user