d15a13e485
- Delete all ops page components (ReviewQueue, AIMonitoring, Governance, SourceMonitoring, ActivityTimeline, SignalPipeline) - Remove OPERATIONS workspace from AppShell config, nav order, path detection - Remove all /ops/* routes from App.tsx - Remove WorkspaceType.OPERATIONS from allowedWorkspaces in authService, sessionStore, permissions - Keep MarketIntelligence page (already moved to /supply/market-intelligence) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
265 lines
9.1 KiB
TypeScript
265 lines
9.1 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 { 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>
|
|
)
|
|
}
|