da50f3b5ea
- Design tokens (ds.ts, theme.ts, scoreTheme.ts): new warm palette (#152642 navy, #f9f8f6 warm white, #e8e7e4 borders, #b8975a gold accent), flat score tier badges replacing CSS gradients, Inter + DM Serif Display typography - Card components: white-background cards, DM Serif score numbers, max-2 badge chips with +N overflow tooltip, editorial score badge positioning - Layout shell: gold left-accent nav active state, 64px top bar, outlined workspace chip, DM Serif page titles - Shared atoms: GenericBadge (outlined/solid variants), ResultFilterBar (simplified chip styles), DecisionContextPanel (dot metrics, no left accent) - Global replacement (118 files): #1e3a5f→#152642, #e2e8f0→#e8e7e4, #f4f6f9→#f9f8f6 — all handled via Node.js for proper UTF-8 safety Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
255 lines
8.6 KiB
TypeScript
255 lines
8.6 KiB
TypeScript
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 { useShallow } from 'zustand/react/shallow'
|
|
import { useAssistantStore } from '../../stores/assistantStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useAssistantSuggestions, useAssistantAnswer } from '../../hooks/useAssistant'
|
|
import { AssistantContextSummary } from './AssistantContextSummary'
|
|
import { AssistantMessageList } from './AssistantMessageList'
|
|
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
|
|
import { AssistantLoadingState } from './AssistantLoadingState'
|
|
import { AssistantErrorState } from './AssistantErrorState'
|
|
import type { AssistantContext } 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
|
|
return null
|
|
}
|
|
|
|
export function GlobalAIAssistantDrawer() {
|
|
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
|
|
useAssistantStore(useShallow(s => s))
|
|
|
|
const { currentUser } = useSessionStore()
|
|
const location = useLocation()
|
|
|
|
const [inputText, setInputText] = useState('')
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
|
|
const answerQuestion = useAssistantAnswer()
|
|
const { data: suggestions = [] } = useAssistantSuggestions(isOpen ? context : null)
|
|
|
|
// Build context from route when drawer opens or 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)
|
|
}, [isOpen, location.pathname])
|
|
|
|
// Auto-scroll on new messages
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
}
|
|
}, [messages, isLoading])
|
|
|
|
const handleQuestion = useCallback((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)
|
|
|
|
const ctx = context ?? {
|
|
currentRoute: location.pathname,
|
|
workspace: resolveWorkspace(location.pathname),
|
|
userRole: currentUser?.role ?? 'VIEWER',
|
|
organizationId: currentUser?.organizationId ?? '',
|
|
}
|
|
|
|
answerQuestion.mutate(
|
|
{ context: ctx, question },
|
|
{
|
|
onSuccess: (answer) => {
|
|
addMessage({
|
|
id: crypto.randomUUID(),
|
|
role: 'assistant',
|
|
createdAt: new Date().toISOString(),
|
|
...answer,
|
|
})
|
|
setLoading(false)
|
|
},
|
|
onError: () => {
|
|
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
|
|
setLoading(false)
|
|
},
|
|
},
|
|
)
|
|
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError, answerQuestion])
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleQuestion(inputText)
|
|
}
|
|
}
|
|
|
|
const handleClear = () => {
|
|
clearConversation()
|
|
}
|
|
|
|
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: '#e8e7e4', 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>
|
|
)
|
|
}
|