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'
|
||||
@@ -39,6 +39,8 @@ import { UserMenu } from './UserMenu'
|
||||
import { NotificationButton } from './NotificationButton'
|
||||
import { RightContextPanel } from './RightContextPanel'
|
||||
import { CompareTray } from './CompareTray'
|
||||
import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant'
|
||||
import { useAssistantStore } from '../../stores/assistantStore'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -435,6 +437,7 @@ interface TopBarProps {
|
||||
function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
||||
const config = WORKSPACE_CONFIG[activeWorkspace]
|
||||
const pageName = getPageNameFromPath(pathname)
|
||||
const openAssistant = useAssistantStore(s => s.open)
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -478,6 +481,7 @@ function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Sparkles size={14} />}
|
||||
onClick={openAssistant}
|
||||
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
|
||||
>
|
||||
AI Assistent
|
||||
@@ -540,6 +544,8 @@ export function AppShell() {
|
||||
</Box>
|
||||
|
||||
<CompareTray />
|
||||
<GlobalAIAssistantDrawer />
|
||||
<GlobalAIAssistantButton />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { WorkspaceType } from './enums'
|
||||
|
||||
export interface AssistantContext {
|
||||
currentRoute: string
|
||||
workspace: WorkspaceType | null
|
||||
selectedEntityType?: string
|
||||
selectedEntityId?: string
|
||||
visibleScores?: Record<string, number>
|
||||
visibleRisks?: string[]
|
||||
visibleMissingData?: string[]
|
||||
availableActions?: string[]
|
||||
userRole: string
|
||||
organizationId: string
|
||||
}
|
||||
|
||||
export interface AssistantAction {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
actionType: 'NAVIGATE' | 'OPEN_REVIEW' | 'ADD_TO_SHORTLIST' | 'REQUEST_DATA' | 'SEND_TO_REVIEW'
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
createdAt: string
|
||||
confidence?: number
|
||||
sources?: string[]
|
||||
actions?: AssistantAction[]
|
||||
}
|
||||
|
||||
export interface SuggestedQuestion {
|
||||
id: string
|
||||
question: string
|
||||
category: string
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import type { AssistantContext, AssistantMessage, SuggestedQuestion, AssistantAction } from '../domain/assistant'
|
||||
|
||||
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||
|
||||
// ── Page-type resolution ───────────────────────────────────────────────────────
|
||||
|
||||
function pageType(route: string): string {
|
||||
if (/\/supply\/properties\/.+/.test(route)) return 'property-detail'
|
||||
if (route.includes('/supply/match-center')) return 'match-center'
|
||||
if (route.includes('/supply/data-quality')) return 'data-quality'
|
||||
if (route.includes('/supply/future-availability')) return 'future-availability'
|
||||
if (route.includes('/supply/dashboard')) return 'supply-dashboard'
|
||||
if (route.includes('/demand/results')) return 'demand-results'
|
||||
if (route.includes('/demand/compare')) return 'compare'
|
||||
if (route.includes('/demand/ai-search')) return 'ai-search'
|
||||
if (route.includes('/ops/review-queue')) return 'review-queue'
|
||||
if (route.includes('/ops/ai-monitoring')) return 'ai-monitoring'
|
||||
return 'general'
|
||||
}
|
||||
|
||||
// ── Suggestions per page type ─────────────────────────────────────────────────
|
||||
|
||||
const SUGGESTIONS: Record<string, SuggestedQuestion[]> = {
|
||||
'property-detail': [
|
||||
{ id: 'pd1', question: 'Warum passt dieses Objekt nicht gut zu aktuellen Gesuchen?', category: 'Match' },
|
||||
{ id: 'pd2', question: 'Welche Daten sollte ich zuerst verbessern?', category: 'Datenqualität' },
|
||||
{ id: 'pd3', question: 'Welche Suchprofile passen am besten zu diesem Objekt?', category: 'Match' },
|
||||
{ id: 'pd4', question: 'Wie gross ist das Risiko, dieses Objekt nicht zu vermieten?', category: 'Risiko' },
|
||||
],
|
||||
'match-center': [
|
||||
{ id: 'mc1', question: 'Welcher eingehende Bedarf hat die höchste Priorität?', category: 'Priorisierung' },
|
||||
{ id: 'mc2', question: 'Warum hat dieser Match einen niedrigen Score?', category: 'Match' },
|
||||
{ id: 'mc3', question: 'Soll ich den Kontakt für diesen Match freigeben?', category: 'Aktion' },
|
||||
],
|
||||
'demand-results': [
|
||||
{ id: 'dr1', question: 'Warum ist dieses Ergebnis an erster Stelle?', category: 'Ranking' },
|
||||
{ id: 'dr2', question: 'Was sind die grössten Kompromisse bei diesem Match?', category: 'Tradeoffs' },
|
||||
{ id: 'dr3', question: 'Sollte ich alternative Standorte in Betracht ziehen?', category: 'Strategie' },
|
||||
{ id: 'dr4', question: 'Welche Hardkriterien werden am häufigsten nicht erfüllt?', category: 'Analyse' },
|
||||
],
|
||||
'compare': [
|
||||
{ id: 'co1', question: 'Welche Option ist strategisch am besten?', category: 'Empfehlung' },
|
||||
{ id: 'co2', question: 'Welche Option hat das höchste Risiko?', category: 'Risiko' },
|
||||
{ id: 'co3', question: 'Welche Option ist am kostengünstigsten?', category: 'Kosten' },
|
||||
],
|
||||
'data-quality': [
|
||||
{ id: 'dq1', question: 'Was sollte ich zuerst beheben?', category: 'Priorität' },
|
||||
{ id: 'dq2', question: 'Welche fehlenden Felder haben den grössten Einfluss auf Matches?', category: 'Impact' },
|
||||
{ id: 'dq3', question: 'Wie verbessere ich den Datenqualitäts-Score schnell?', category: 'Optimierung' },
|
||||
],
|
||||
'future-availability': [
|
||||
{ id: 'fa1', question: 'Warum ist dieses Signal probabilistisch und nicht bestätigt?', category: 'Erklärung' },
|
||||
{ id: 'fa2', question: 'Welche Belege unterstützen dieses Signal?', category: 'Evidenz' },
|
||||
{ id: 'fa3', question: 'Was muss vor der Freigabe an Demand-Nutzer geprüft werden?', category: 'Review' },
|
||||
{ id: 'fa4', question: 'Wie hoch ist die Konfidenz dieses Signals?', category: 'Konfidenz' },
|
||||
],
|
||||
'review-queue': [
|
||||
{ id: 'rq1', question: 'Welche Review-Aufgabe sollte ich zuerst bearbeiten?', category: 'Priorisierung' },
|
||||
{ id: 'rq2', question: 'Was sind die Kriterien für eine Genehmigung?', category: 'Prozess' },
|
||||
{ id: 'rq3', question: 'Wann sollte ich eine Aufgabe eskalieren?', category: 'Eskalation' },
|
||||
],
|
||||
'ai-monitoring': [
|
||||
{ id: 'am1', question: 'Welche fehlgeschlagenen Outputs haben die höchste Priorität?', category: 'Fehler' },
|
||||
{ id: 'am2', question: 'Was bedeutet ein Schema-Validierungsfehler?', category: 'Fehleranalyse' },
|
||||
{ id: 'am3', question: 'Welche AI-Outputs brauchen eine manuelle Review?', category: 'Review' },
|
||||
],
|
||||
'general': [
|
||||
{ id: 'g1', question: 'Wie kann ich meine Daten für bessere Matches vorbereiten?', category: 'Optimierung' },
|
||||
{ id: 'g2', question: 'Was sind die wichtigsten KPIs in dieser Ansicht?', category: 'Überblick' },
|
||||
{ id: 'g3', question: 'Welche nächste Aktion empfiehlst du?', category: 'Aktion' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Answer templates ───────────────────────────────────────────────────────────
|
||||
|
||||
type AnswerPayload = {
|
||||
content: string
|
||||
confidence: number
|
||||
sources: string[]
|
||||
actions?: AssistantAction[]
|
||||
}
|
||||
|
||||
type Template = {
|
||||
keywords: string[]
|
||||
generate: (ctx: AssistantContext) => AnswerPayload
|
||||
}
|
||||
|
||||
const entityRef = (ctx: AssistantContext) =>
|
||||
ctx.selectedEntityId ? ` (${ctx.selectedEntityId})` : ''
|
||||
|
||||
const missingFields = (ctx: AssistantContext) =>
|
||||
ctx.visibleMissingData?.slice(0, 3).join(', ') ?? 'Mietpreis/m², Verfügbarkeit'
|
||||
|
||||
const scoreVal = (ctx: AssistantContext, key: string, fallback = 72) =>
|
||||
ctx.visibleScores?.[key] ?? fallback
|
||||
|
||||
const TEMPLATES: Record<string, Template[]> = {
|
||||
'property-detail': [
|
||||
{
|
||||
keywords: ['passt', 'match', 'score', 'niedrig'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Das Objekt${entityRef(_ctx)} erreicht einen Datenqualitätsscore von ${scoreVal(_ctx, 'quality')}%. Damit liegt es unter dem empfohlenen Schwellenwert von 70%, der für präzises Matching erforderlich ist.\n\nDie häufigsten Faktoren, die Matches verhindern:\n• Fehlende oder veraltete Felder (${missingFields(_ctx)})\n• Unklare Verfügbarkeitsangaben – kritisch für zeitbasierte Gesuche\n• Fehlende Zertifizierungen, wenn Demand-Profile spezifische Anforderungen haben\n\nEmpfehlung: Qualitätsfelder priorisieren, um den Score auf ≥75% zu bringen und die Sichtbarkeit in der Trefferquote zu erhöhen.`,
|
||||
confidence: 0.86,
|
||||
sources: ['Datenqualität', 'Match-Score-Berechnung'],
|
||||
actions: [
|
||||
{ id: 'a1', label: 'Zur Datenpflege', description: 'Datenqualität dieses Objekts verbessern', actionType: 'NAVIGATE', payload: { path: '/supply/data-quality' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['verbessern', 'zuerst', 'priorität', 'beheben', 'felder'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Für Objekt${entityRef(_ctx)} empfehle ich folgende Reihenfolge:\n\n**1. ${_ctx.visibleMissingData?.[0] ?? 'Mietpreis/m²'}** (kritisch)\nDirekte Auswirkung auf 60–70% aller Bedarfsanfragen. Ohne Preisinformation kein Matching möglich.\n\n**2. ${_ctx.visibleMissingData?.[1] ?? 'Verfügbarkeitsdatum'}** (hoch)\nZeitbasierte Gesuche schliessen Objekte ohne klares Datum aus.\n\n**3. ${_ctx.visibleMissingData?.[2] ?? 'Fläche m²'}** (mittel)\nBestimmt, ob Flächenkriterien erfüllt werden.\n\nNach diesen drei Feldern sollte der Qualitätsscore um ~15–20 Punkte steigen.`,
|
||||
confidence: 0.91,
|
||||
sources: ['Datenqualität', 'Feldgewichtung'],
|
||||
actions: [
|
||||
{ id: 'a2', label: 'Felder aktualisieren', description: 'Objekt-Detailansicht öffnen und Felder bearbeiten', actionType: 'NAVIGATE', payload: { path: '/supply/properties' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['suchprofile', 'gesuche', 'demand', 'passend', 'passen'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Basierend auf dem aktuellen Objekt${entityRef(_ctx)} würden vor allem Profile mit folgenden Eigenschaften passen:\n\n• **Büro / Open Space** – sofern Grundriss offen oder teilbar\n• **Mittleres Budget** (CHF 8'000–14'000/Mt) – entspricht typischer Preisrange\n• **Kurzfristige Verfügbarkeit** (≤3 Monate) – hohe Nachfrage in diesem Segment\n\nFür genaue Profilvorschläge: Den Match-Center öffnen und die Trefferrate mit aktuellen Gesuchen prüfen.`,
|
||||
confidence: 0.78,
|
||||
sources: ['Match-Center', 'Demand-Profile-Analyse'],
|
||||
actions: [
|
||||
{ id: 'a3', label: 'Match-Center öffnen', description: 'Eingehende Bedarfe für dieses Objekt anzeigen', actionType: 'NAVIGATE', payload: { path: '/supply/match-center' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['risiko', 'risk', 'leerstand', 'vermieten'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Das Leerstandsrisiko für Objekt${entityRef(_ctx)} hängt von drei Faktoren ab:\n\n• **Datenqualität** (${scoreVal(_ctx, 'quality')}%) – Niedrige Qualität reduziert Sichtbarkeit in Suchergebnissen\n• **Marktlage** – Aktuelle Signale deuten auf moderate Nachfrage in diesem Segment hin\n• **Preispositionierung** – Ohne Marktpreisvergleich keine verlässliche Einschätzung möglich\n\n**Hinweis:** Diese Einschätzung basiert auf verfügbaren Metadaten. Für eine fundierte Leerstandsprognose wird eine vollständige Datenbasis empfohlen.`,
|
||||
confidence: 0.71,
|
||||
sources: ['Datenqualität', 'Marktindikatoren'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
'demand-results': [
|
||||
{
|
||||
keywords: ['ersten', 'erst', 'ranking', 'warum', 'platz'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Das erstplatzierte Ergebnis${entityRef(_ctx)} erreicht diesen Rang, weil es die meisten Hardkriterien vollständig erfüllt. Im Scoring-Modell zählen Hardkriterien mit 60% Gewichtung – ein Objekt mit 5/5 Hardkriterien übertrifft alle Objekte mit auch nur einem unerfüllten Kriterium.\n\nZusätzlich fliessen Softfaktoren (40%) ein: Standortqualität, Verfügbarkeitsübereinstimmung und Ausbaustandard.\n\nFür Details zur Begründung: "Match-Erklärung" in der Detailansicht öffnen.`,
|
||||
confidence: 0.89,
|
||||
sources: ['Match-Score', 'Scoring-Modell'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['kompromiss', 'trade', 'nachteil', 'tradeoff', 'opfer'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Die grössten Kompromisse bei diesem Match:\n\n• **Preis vs. Fläche** – Das Objekt liegt ggf. über Budget, bietet aber mehr Fläche als Minimum\n• **Lage vs. Ausbaustandard** – Zentralere Lage geht oft mit höherem Mietpreis einher\n• **Verfügbarkeit** – Falls Objekt erst in 4+ Monaten frei wird, widerspricht das kurzfristigen Bedarfen\n\n**Empfehlung:** Tradeoffs mit dem Suchenden diskutieren – was ist verhandelbar, was ist ein Ausschlusskriterium?`,
|
||||
confidence: 0.83,
|
||||
sources: ['Match-Score', 'Hardkriterien-Analyse'],
|
||||
actions: [
|
||||
{ id: 'a4', label: 'Vergleichsansicht öffnen', description: 'Ergebnis mit anderen Matches vergleichen', actionType: 'NAVIGATE', payload: { path: '/demand/compare' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['alternative', 'standort', 'lage', 'andere'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Alternative Standorte lohnen sich zu prüfen, wenn:\n\n• Die Top-Ergebnisse alle im selben Preissegment liegen und Budget ein Engpass ist\n• Die Anforderungen an Lage verhandelbar sind (z.B. Zürich 1–4 statt nur 1)\n• Suchprofile mit erweiterter Standorttoleranz signifikant bessere Treffer zeigen\n\n**Konkret:** Im AI-Suche-Formular die Standortangabe auf Stadtkreis oder Kanton ausweiten und neu suchen. Dies kann die Trefferanzahl um 30–60% erhöhen.`,
|
||||
confidence: 0.80,
|
||||
sources: ['Suchanfrage-Analyse', 'Standort-Scoring'],
|
||||
actions: [
|
||||
{ id: 'a5', label: 'Suche anpassen', description: 'Zurück zur Flächensuche mit erweiterter Standortauswahl', actionType: 'NAVIGATE', payload: { path: '/demand/ai-search' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['hardkriterien', 'kriterien', 'nicht erfüllt', 'ausschlusskriterium'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Häufig nicht erfüllte Hardkriterien in den aktuellen Ergebnissen:\n\n• **Flächengrösse** – Viele Objekte liegen 10–20% unter dem Mindestwert\n• **Verfügbarkeitsdatum** – Diskrepanz zwischen gewünschtem Einzugsdatum und tatsächlicher Verfügbarkeit\n• **Parkplatzkontingent** – Wenige Objekte bieten die geforderte Anzahl Stellplätze\n\nHinweis: Hardkriterien sind binär – ein nicht erfülltes Kriterium schiesst ein Objekt vollständig aus dem Ranking aus, unabhängig von anderen Stärken.`,
|
||||
confidence: 0.88,
|
||||
sources: ['Matching-Engine', 'Kriterien-Gewichtung'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
'compare': [
|
||||
{
|
||||
keywords: ['strategisch', 'best', 'empfehlung', 'wählen'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Für eine strategische Empfehlung werden folgende Dimensionen gewichtet:\n\n• **Match-Score** – Wie gut erfüllt das Objekt das Suchprofil?\n• **Datenqualität** – Je vollständiger, desto verlässlicher die Einschätzung\n• **Zeitliche Verfügbarkeit** – Passt der Einzugstermin zur Planung?\n• **Preis-Leistung** – Mietpreis im Verhältnis zu Fläche und Ausstattung\n\n**Hinweis:** Die finale Entscheidung muss durch den Nutzer getroffen werden. Der Assistant kann Faktoren gewichten, aber keine verbindliche Empfehlung ohne vollständige Datenbasis abgeben.`,
|
||||
confidence: 0.77,
|
||||
sources: ['Vergleichsansicht', 'Match-Scores'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['risiko', 'höchste', 'gefährlich', 'risikoreiche'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Risikoindikatoren im Vergleich:\n\n• **Niedrige Datenqualität** (<65%) = höheres Informationsrisiko – Angaben nicht verlässlich verifiziert\n• **Niedrige Konfidenz** (<60%) = Scoring-Unsicherheit – Match könnte sich bei mehr Daten verschlechtern\n• **Fehlende Verfügbarkeitsangabe** = Planungsrisiko – keine verbindliche Zusage möglich\n\nDas Objekt mit dem niedrigsten Konfidenz-Score trägt das höchste strukturelle Risiko, weil die Basis für den Match-Score unvollständig ist.`,
|
||||
confidence: 0.84,
|
||||
sources: ['Konfidenz-Scores', 'Datenqualität'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['kosten', 'günstig', 'preis', 'effektiv', 'billiger'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Kostenbewertung im Vergleich:\n\nDie reine Mietkosten-Betrachtung reicht nicht aus. Relevant ist der **Preis pro m²** im Verhältnis zu:\n• Ausstattungsstandard und Renovierungszustand\n• Nebenkosten und Betriebskosten\n• Lagequalität (ÖPNV, Infrastruktur)\n\nEin günstigeres Objekt mit hohem Renovierungsbedarf kann mittelfristig teurer werden als ein teureres, bezugsbereites Objekt.\n\n**Tipp:** Mietpreis/m² in der Vergleichstabelle nebeneinander stellen und Gesamtkosten über Mietdauer schätzen.`,
|
||||
confidence: 0.79,
|
||||
sources: ['Preisangaben', 'Kostenvergleich'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
'data-quality': [
|
||||
{
|
||||
keywords: ['zuerst', 'priorität', 'erst', 'beheben', 'anfangen'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**Empfohlene Prioritäten für sofortigen Impact:**\n\n1. **${_ctx.visibleMissingData?.[0] ?? 'Mietpreis/m²'}** — Kritisch\nOhne Preisinformation werden Objekte aus preissensitiven Suchanfragen ausgeschlossen.\n\n2. **${_ctx.visibleMissingData?.[1] ?? 'Verfügbarkeitsdatum'}** — Hoch\nZeitbasierte Matching-Logik erfordert ein konkretes Datum.\n\n3. **${_ctx.visibleMissingData?.[2] ?? 'Adresse / Koordinaten'}** — Mittel\nSuchradius-Filter benötigen geografische Verortung.\n\nNach diesen drei Feldern ist ein Qualitätsscore von ≥75% erreichbar – der Schwellenwert für volle Matching-Sichtbarkeit.`,
|
||||
confidence: 0.93,
|
||||
sources: ['Feldgewichtung', 'Matching-Regeln'],
|
||||
actions: [
|
||||
{ id: 'a6', label: 'Objekt bearbeiten', description: 'Kritische Felder in der Objektansicht aktualisieren', actionType: 'NAVIGATE', payload: { path: '/supply/properties' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['fehlende', 'felder', 'impact', 'einfluss', 'auswirkung'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Einfluss fehlender Felder auf Match-Trefferquote:\n\n| Feld | Ausschlussquote |\n|------|----------------|\n| Mietpreis | ~65% aller Gesuche |\n| Fläche m² | ~80% aller Gesuche |\n| Verfügbarkeit | ~50% zeitkritischer Gesuche |\n| Zertifizierungen | ~20–30% spezifischer Gesuche |\n\nDie Fläche hat die grösste Ausschlussquote, da sie das primäre Hardkriterium für nahezu alle Suchprofile ist.`,
|
||||
confidence: 0.90,
|
||||
sources: ['Matching-Engine', 'Statistik-Analyse'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['score', 'verbessern', 'erhöhen', 'schnell', 'steigern'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Schnellste Wege zur Score-Verbesserung:\n\n• **Vollständigkeits-Boost** (+15–20 Punkte): Die 3 wichtigsten kritischen Felder befüllen\n• **Aktualitäts-Boost** (+5–10 Punkte): Letzte Aktualisierung auf heute setzen\n• **Verifikations-Boost** (+10 Punkte): Quellenangaben zu Preisen und Verfügbarkeit hinzufügen\n\nHinweis: Der Qualitätsscore wird bei jeder Änderung neu berechnet. Kein Warten nötig.`,
|
||||
confidence: 0.87,
|
||||
sources: ['Score-Berechnung', 'Feldgewichtung'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
'future-availability': [
|
||||
{
|
||||
keywords: ['probabilistisch', 'bestätigt', 'nicht bestätigt', 'warum', 'unbestätigt'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**Warum ist das Signal probabilistisch?**\n\nDieses Signal basiert auf indirekten Datenquellen (Baugesuche, Stellenausschreibungen, Pressemitteilungen) – nicht auf einer direkten Bestätigung durch den Vermieter oder Eigentümer.\n\nDie Verfügbarkeit ist eine **Wahrscheinlichkeitsaussage**, keine Tatsache. Das bedeutet:\n• Die Fläche ist möglicherweise noch nicht auf dem Markt\n• Die Zeitangabe kann sich verschieben\n• Eine alternative Nutzung ist nicht ausgeschlossen\n\n⚠️ Demand-Nutzern gegenüber darf dieses Signal nie als bestätigte Verfügbarkeit kommuniziert werden.`,
|
||||
confidence: 0.95,
|
||||
sources: ['Signal-Typ', 'Quellenklassifikation'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['belege', 'evidence', 'beweise', 'unterstützen', 'daten'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Belege für dieses Signal werden aus folgenden Quellen abgeleitet:\n\n• **Quellentyp** des Signals (z.B. Baugesuch, Jobausschreibung, Pressemitteilung)\n• **Erscheinungsdatum** der Quelle\n• **Konfidenzwert** basierend auf Quellenzuverlässigkeit und Korroborierung\n\nFür spezifische Belege: Signal-Detailansicht öffnen → Abschnitt "Evidenz".\n\nHinweis: Ein einzelner Beleg ohne Korroborierung senkt den Konfidenzwert. Mehrere unabhängige Quellen erhöhen ihn.`,
|
||||
confidence: 0.88,
|
||||
sources: ['Evidenz-Modul', 'Quellen-Klassifikation'],
|
||||
actions: [
|
||||
{ id: 'a7', label: 'Signal-Details öffnen', description: 'Evidenz-Abschnitt für dieses Signal anzeigen', actionType: 'NAVIGATE', payload: { path: '/supply/future-availability' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['review', 'prüfen', 'freigabe', 'zeigen', 'demand'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**Vor der Freigabe an Demand-Nutzer empfehle ich:**\n\n1. **Konfidenz prüfen** – Signal sollte ≥60% haben, sonst nur intern sichtbar lassen\n2. **Sensitivitätsstufe prüfen** – CONFIDENTIAL-Signale nie extern zeigen\n3. **Review-Status** – Signal muss mindestens IN_REVIEW-Status haben\n4. **Haftungshinweis** – Disclaimer muss für Demand-Nutzer sichtbar sein\n\nFür die Freigabe: "Zur Prüfung senden" in der Signal-Detailansicht klicken.`,
|
||||
confidence: 0.92,
|
||||
sources: ['Review-Workflow', 'Disclosure-Regeln'],
|
||||
actions: [
|
||||
{ id: 'a8', label: 'Review Queue öffnen', description: 'Signal zur manuellen Prüfung übergeben', actionType: 'OPEN_REVIEW' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['konfidenz', 'wahrscheinlichkeit', 'probability', 'genau'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Der Konfidenzwert für dieses Signal setzt sich zusammen aus:\n\n• **Quellenqualität** (0–40%): Offizielle Quellen (Baugesuche, Amtsblatt) zählen höher als Pressemitteilungen\n• **Zeitnähe** (0–30%): Ältere Quellen werden abgewertet\n• **Korroborierung** (0–30%): Mehrere unabhängige Quellen erhöhen den Wert\n\nEin Wert unter 50% deutet auf unzuverlässige oder einzelne Quellen hin und sollte als "beobachtenswert, nicht aktionierbar" behandelt werden.`,
|
||||
confidence: 0.85,
|
||||
sources: ['Konfidenz-Berechnung', 'Quellengewichtung'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
'review-queue': [
|
||||
{
|
||||
keywords: ['zuerst', 'priorität', 'dringend', 'welche'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**Priorisierung der Review Queue:**\n\nEmpfohlene Reihenfolge nach Dringlichkeit:\n\n1. **CRITICAL + ESCALATED** – Sofortiger Handlungsbedarf, meist rechtliche oder Compliance-Relevanz\n2. **HIGH + PENDING** – Warten auf Entscheidung, können Prozesse blockieren\n3. **Fälligkeitsdatum überschritten** – Unabhängig von Priorität\n4. **MEDIUM + IN_REVIEW** – Bereits in Bearbeitung, weiterführen\n\nAufgaben ohne Fälligkeitsdatum und mit LOW-Priorität können gebündelt am Ende bearbeitet werden.`,
|
||||
confidence: 0.90,
|
||||
sources: ['Review-Queue-Regeln', 'Prioritäts-Framework'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['kriterien', 'genehmigen', 'ablehnen', 'genehmigung'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**Entscheidungskriterien:**\n\n✅ **Genehmigen**, wenn:\n• Alle Pflichtfelder vorhanden und plausibel\n• Konfidenz ≥65%\n• Kein offensichtlicher Datenfehler\n• Quellen verifizierbar\n\n❌ **Ablehnen**, wenn:\n• Schema-Validierungsfehler vorliegt\n• Inhalte nachweislich falsch oder irreführend\n• Datenschutz-Bedenken nicht ausgeräumt\n\n⚠️ **Mehr Daten anfordern**, wenn:\n• Wichtige Felder fehlen aber beschaffbar sind\n• Quelle unklar, aber plausibel`,
|
||||
confidence: 0.93,
|
||||
sources: ['Governance-Richtlinien', 'Review-Protokoll'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['eskalier', 'eskalation', 'wann', 'hochstufen'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**Eskalation ist angemessen wenn:**\n\n• Die Entscheidung Rechtsfolgen hat (Datenschutz, GDPR, Mietrecht)\n• Konflikte zwischen Stakeholdern nicht auf Reviewer-Ebene lösbar sind\n• Der Review-Task eine Geschäftsentscheidung mit hohem Risiko erfordert\n• Zwei Reviewer zu unterschiedlichen Ergebnissen kommen\n\nEskalierte Tasks landen bei der Organisationsleitung. Nutzung sparsam empfohlen – zu viele Eskalationen entwerten das Signal.`,
|
||||
confidence: 0.88,
|
||||
sources: ['Eskalations-Framework', 'Governance'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
'ai-monitoring': [
|
||||
{
|
||||
keywords: ['fehler', 'fehlgeschlagen', 'priorität', 'wichtig'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**Fehler-Triage in der Reihenfolge:**\n\n1. **SCHEMA_VALIDATION** – Höchste Priorität. Output wurde nicht an die UI geliefert. Nutzer hat möglicherweise unvollständige Informationen erhalten.\n2. **EMPTY_RESPONSE** – Hoch. Funktion hat komplett versagt. Retry empfehlenswert.\n3. **INVALID_JSON** – Mittel. Output war vorhanden, aber nicht verarbeitbar. Recovery oft möglich.\n4. **PROVIDER_TIMEOUT** – Niedrig bis Mittel. Meist temporäres Problem. Retry oder Fallback prüfen.\n\nFür alle Fehler mit FLAGGED-Status: Review-Aufgabe erstellen, um manuellen Check zu dokumentieren.`,
|
||||
confidence: 0.91,
|
||||
sources: ['Fehler-Klassifikation', 'AI-Monitoring'],
|
||||
actions: [
|
||||
{ id: 'a9', label: 'Fehler filtern', description: 'AI-Monitoring-Tabelle auf Fehler filtern', actionType: 'NAVIGATE', payload: { path: '/ops/ai-monitoring' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['schema', 'validierung', 'schema-fehler', 'bedeutet'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**Schema-Validierungsfehler erklärt:**\n\nEin Schema-Validierungsfehler bedeutet, dass der AI-Output zwar generiert wurde, aber nicht der erwarteten Datenstruktur entspricht.\n\n**Mögliche Ursachen:**\n• Pflichtfeld fehlt im Output (z.B. 'hardCriteria')\n• Falscher Datentyp (z.B. String statt Number)\n• Prompt-/Schema-Versions-Mismatch\n\n**Konsequenz:** Der Output wurde **nicht** an die UI ausgeliefert – der Nutzer hat kein fehlerhaftes Resultat gesehen.\n\n**Massnahme:** Prompt-Version und Schema-Version prüfen, ggf. Prompt aktualisieren.`,
|
||||
confidence: 0.94,
|
||||
sources: ['Schema-Validierung', 'AI-Pipeline'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['review', 'manuell', 'prüfung', 'brauchen'],
|
||||
generate: (_ctx) => ({
|
||||
content: `**AI-Outputs, die manuelle Review brauchen:**\n\n• Status **FLAGGED** – wurde automatisch als problematisch markiert\n• Status **UNREVIEWED** + Fehler vorhanden – hohe Priorität\n• Outputs mit **DECISION_BRIEF** oder **MATCH_EXPLANATION** Typ – direkte Auswirkung auf Nutzerentscheidungen\n• Latenz >5s – deutet auf Qualitätsprobleme hin\n\nOutput direkt in der Review Queue anlegen: "Zur Prüfung" Button in der Detail-Ansicht.`,
|
||||
confidence: 0.89,
|
||||
sources: ['Review-Regeln', 'AI-Monitoring'],
|
||||
actions: [
|
||||
{ id: 'a10', label: 'Review Queue öffnen', description: 'Zur Review Queue navigieren', actionType: 'NAVIGATE', payload: { path: '/ops/review-queue' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
'general': [
|
||||
{
|
||||
keywords: ['kpi', 'kennzahlen', 'überblick', 'metriken'],
|
||||
generate: () => ({
|
||||
content: `Die wichtigsten KPIs je Workspace:\n\n**Verwaltung (Supply):**\n• Datenqualitäts-Score (Ziel: ≥70%)\n• Match-Rate (Anteil Objekte mit ≥1 aktivem Match)\n\n**Suche (Demand):**\n• Trefferquote (Ergebnisse mit Score ≥70%)\n• Hardkriterien-Erfüllungsrate\n\n**Administration (Ops):**\n• Offene Review-Tasks\n• AI-Fehlerrate\n• Genehmigungsrate`,
|
||||
confidence: 0.82,
|
||||
sources: ['Dashboard', 'Monitoring'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['nächste', 'aktion', 'empfehlung', 'was tun', 'handlung'],
|
||||
generate: (_ctx) => ({
|
||||
content: `Empfohlene nächste Aktionen basierend auf dem aktuellen Workspace:\n\n• **Datenpflege-Backlog abarbeiten** – Objekte unter 65% Qualitätsscore priorisieren\n• **Review Queue prüfen** – Offene CRITICAL-Tasks zuerst\n• **AI-Fehler quittieren** – FLAGGED-Outputs in AI-Monitoring markieren\n\nDer Assistant kann konkretere Empfehlungen geben, wenn eine spezifische Seite (Objekt, Match, Signal) geöffnet ist.`,
|
||||
confidence: 0.75,
|
||||
sources: ['Kontextanalyse'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
keywords: ['daten', 'vorbereiten', 'matches', 'bessere'],
|
||||
generate: () => ({
|
||||
content: `**Daten für bessere Matches vorbereiten:**\n\n1. **Vollständigkeit** – Alle Pflichtfelder (Fläche, Preis, Verfügbarkeit, Adresse) befüllen\n2. **Aktualität** – Veraltete Angaben (>6 Monate) aktualisieren\n3. **Präzision** – Exakte m²-Angaben statt Schätzwerte\n4. **Kontext** – Beschreibung von Ausstattung und Besonderheiten hilft der semantischen Suche\n\nJedes komplett befüllte und aktuelle Objekt erhöht die Match-Sichtbarkeit signifikant.`,
|
||||
confidence: 0.88,
|
||||
sources: ['Matching-Regeln', 'Best-Practices'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ── Template matching ─────────────────────────────────────────────────────────
|
||||
|
||||
function findTemplate(question: string, pageCtx: string): Template | null {
|
||||
const q = question.toLowerCase()
|
||||
const bucket = TEMPLATES[pageCtx] ?? TEMPLATES['general'] ?? []
|
||||
for (const t of bucket) {
|
||||
if (t.keywords.some(kw => q.includes(kw))) return t
|
||||
}
|
||||
return bucket[0] ?? TEMPLATES['general']?.[0] ?? null
|
||||
}
|
||||
|
||||
// ── Public service API ────────────────────────────────────────────────────────
|
||||
|
||||
export const aiAssistantService = {
|
||||
async getSuggestions(context: AssistantContext): Promise<SuggestedQuestion[]> {
|
||||
await delay(200)
|
||||
const page = pageType(context.currentRoute)
|
||||
return (SUGGESTIONS[page] ?? SUGGESTIONS['general']).slice(0, 4)
|
||||
},
|
||||
|
||||
async answerQuestion(context: AssistantContext, question: string): Promise<Omit<AssistantMessage, 'id' | 'role' | 'createdAt'>> {
|
||||
await delay(700 + Math.random() * 700)
|
||||
const page = pageType(context.currentRoute)
|
||||
const template = findTemplate(question, page) ?? findTemplate(question, 'general')
|
||||
|
||||
if (!template) {
|
||||
return {
|
||||
content: 'Zu dieser Frage liegen derzeit keine ausreichenden Kontextdaten vor. Bitte öffnen Sie eine spezifische Objekt- oder Match-Ansicht und stellen Sie die Frage erneut.',
|
||||
confidence: 0.5,
|
||||
sources: [],
|
||||
}
|
||||
}
|
||||
|
||||
return template.generate(context)
|
||||
},
|
||||
|
||||
async createActionFromAnswer(_action: import('../domain/assistant').AssistantAction): Promise<{ success: boolean }> {
|
||||
await delay(100)
|
||||
return { success: true }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { create } from 'zustand'
|
||||
import type { AssistantContext, AssistantMessage } from '../domain/assistant'
|
||||
|
||||
interface AssistantState {
|
||||
isOpen: boolean
|
||||
context: AssistantContext | null
|
||||
messages: AssistantMessage[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
open: () => void
|
||||
close: () => void
|
||||
setContext: (ctx: AssistantContext) => void
|
||||
updateContext: (partial: Partial<AssistantContext>) => void
|
||||
addMessage: (msg: AssistantMessage) => void
|
||||
setLoading: (v: boolean) => void
|
||||
setError: (e: string | null) => void
|
||||
clearConversation: () => void
|
||||
}
|
||||
|
||||
export const useAssistantStore = create<AssistantState>((set) => ({
|
||||
isOpen: false,
|
||||
context: null,
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
setContext: (ctx) => set({ context: ctx }),
|
||||
updateContext: (partial) => set((s) => ({ context: s.context ? { ...s.context, ...partial } : null })),
|
||||
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
|
||||
setLoading: (v) => set({ isLoading: v }),
|
||||
setError: (e) => set({ error: e }),
|
||||
clearConversation: () => set({ messages: [], error: null }),
|
||||
}))
|
||||
Reference in New Issue
Block a user