This commit is contained in:
seb
2026-10-04 02:13:50 +02:00
parent 0d0c5dfdfb
commit f2fce9097b
19 changed files with 3087 additions and 9 deletions

View File

@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
import SendRoundedIcon from '@mui/icons-material/SendRounded';
export default function AskUserPrompt({ askData, onAnswer, disabled = false }) {
const { toolCallId, question, options = [] } = askData;
const [customAnswer, setCustomAnswer] = useState('');
const [showCustomInput, setShowCustomInput] = useState(false);
const handleSelectOption = (opt) => {
if (disabled) return;
onAnswer({ tool_call_id: toolCallId, answer: opt });
};
const handleCustomSubmit = (e) => {
e?.preventDefault();
const trimmed = customAnswer.trim();
if (!trimmed || disabled) return;
onAnswer({ tool_call_id: toolCallId, answer: trimmed });
};
return (
<Box
sx={(theme) => ({
mx: 2,
my: 1,
p: 2,
borderRadius: 3,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(59, 91, 219, 0.12)' : '#eef2ff',
border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(59, 91, 219, 0.3)' : '#c7d2fe'}`,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
})}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'primary.main' }}>
<HelpOutlineRoundedIcon sx={{ fontSize: 18 }} />
<Typography variant="subtitle2" fontWeight={700} sx={{ color: 'text.primary' }}>
{question || 'Frage des Modells'}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{options.map((opt, idx) => (
<Button
key={idx}
variant="outlined"
size="small"
disabled={disabled}
onClick={() => handleSelectOption(opt)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
borderRadius: 2,
px: 1.5,
py: 0.75,
fontSize: '0.82rem',
bgcolor: 'background.paper',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
{opt}
</Button>
))}
{!showCustomInput ? (
<Button
variant="outlined"
size="small"
disabled={disabled}
onClick={() => setShowCustomInput(true)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
borderRadius: 2,
px: 1.5,
py: 0.75,
fontSize: '0.82rem',
bgcolor: 'background.paper',
borderStyle: 'dashed',
color: 'text.secondary',
}}
>
Eigene Antwort eingeben…
</Button>
) : (
<Box
component="form"
onSubmit={handleCustomSubmit}
sx={{ display: 'flex', gap: 1, mt: 0.5, alignItems: 'center' }}
>
<TextField
size="small"
fullWidth
autoFocus
placeholder="Antwort eingeben…"
value={customAnswer}
onChange={(e) => setCustomAnswer(e.target.value)}
disabled={disabled}
slotProps={{
htmlInput: {
spellCheck: false,
autoCorrect: 'off',
autoCapitalize: 'off',
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
bgcolor: 'background.paper',
fontSize: '0.82rem',
},
}}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={disabled || !customAnswer.trim()}
endIcon={<SendRoundedIcon sx={{ fontSize: 16 }} />}
sx={{
borderRadius: 2,
px: 2,
py: 0.9,
whiteSpace: 'nowrap',
textTransform: 'none',
}}
>
Antworten
</Button>
</Box>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,680 @@
import React, { PureComponent } from 'react';
import Drawer from '@mui/material/Drawer';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import SendRoundedIcon from '@mui/icons-material/SendRounded';
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat';
import ChatFlowList from './ChatFlowList';
import AskUserPrompt from './AskUserPrompt';
const DEFAULT_DRAWER_WIDTH = 460;
const MIN_DRAWER_WIDTH = 320;
export default class ChatDrawer extends PureComponent {
constructor(props) {
super(props);
const s = chatAutomat.state;
const settings = userSettingsAutomat.state;
this.state = {
isOpen: s.isOpen,
conversations: s.conversations,
selectedConversationId: s.selectedConversationId,
draftConversation: s.draftConversation,
conversationState: s.conversationState,
provider: s.provider,
model: s.model,
messages: s.messages,
loadingMessages: s.loadingMessages,
streamingText: s.streamingText,
isSending: s.isSending,
loadingConversations: s.loadingConversations,
sseStatus: s.sseStatus,
sendError: s.sendError,
inputText: '',
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
isResizing: false,
};
this.inputRef = React.createRef();
this.focusTimeout = null;
}
focusInput = (delay = 50) => {
if (this.focusTimeout) clearTimeout(this.focusTimeout);
this.focusTimeout = setTimeout(() => {
this.inputRef.current?.focus?.();
}, delay);
};
componentDidMount() {
if (this.state.isOpen) {
this.focusInput(150);
}
this.unsub = chatAutomat.subscribe(this, (state) => {
const wasOpen = this.state.isOpen;
const willOpen = state.isOpen;
if (!wasOpen && willOpen) {
this.focusInput(150);
}
return {
isOpen: state.isOpen,
conversations: state.conversations,
selectedConversationId: state.selectedConversationId,
draftConversation: state.draftConversation,
conversationState: state.conversationState,
provider: state.provider,
model: state.model,
messages: state.messages,
loadingMessages: state.loadingMessages,
streamingText: state.streamingText,
isSending: state.isSending,
loadingConversations: state.loadingConversations,
sseStatus: state.sseStatus,
sendError: state.sendError,
};
});
this.unsubSettings = userSettingsAutomat.subscribe(this, (state) => {
if (!this.state.isResizing) {
return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH };
}
return null;
});
}
componentWillUnmount() {
if (this.focusTimeout) clearTimeout(this.focusTimeout);
this.unsub?.();
this.unsubSettings?.();
this.cleanupResizeListeners();
}
handleClose = () => {
chatAutomat.actions.closeChat();
};
handleResizeStart = (e) => {
if (e.button !== undefined && e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX);
if (startX === undefined) return;
const startWidth = this.state.drawerWidth;
this.setState({ isResizing: true });
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
let currentWidth = startWidth;
const onMove = (moveEvt) => {
moveEvt.preventDefault();
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
if (clientX === undefined) return;
const deltaX = startX - clientX;
const maxWidth = Math.min(window.innerWidth - 40, 1400);
const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX)));
currentWidth = newWidth;
this.setState({ drawerWidth: newWidth });
};
const onEnd = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onEnd);
window.removeEventListener('pointercancel', onEnd);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onEnd);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
this.setState({ isResizing: false });
userSettingsAutomat.actions.setChatDrawerWidth(currentWidth);
};
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', onEnd);
window.addEventListener('pointercancel', onEnd);
window.addEventListener('mousemove', onMove, { passive: false });
window.addEventListener('mouseup', onEnd);
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
};
handleResizeDoubleClick = () => {
this.setState({ drawerWidth: DEFAULT_DRAWER_WIDTH });
userSettingsAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH);
};
cleanupResizeListeners = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
handleSelectConversation = (e) => {
const id = Number(e.target.value);
if (id) {
chatAutomat.actions.selectConversation(id);
this.focusInput(50);
}
};
handleNewChat = () => {
chatAutomat.actions.startDraft();
this.focusInput(50);
};
handleDeleteConversation = async (e, convId) => {
e?.stopPropagation();
e?.preventDefault();
if (!convId) return;
try {
await chatAutomat.actions.deleteConversation(convId);
} catch (err) {
console.error('[ChatDrawer] Failed to delete conversation:', err);
} finally {
this.focusInput(50);
}
};
handleSend = async (e) => {
e?.preventDefault();
const text = this.state.inputText.trim();
if (!text || this.state.conversationState === 'busy' || this.state.isSending) {
return;
}
this.setState({ inputText: '' });
try {
await chatAutomat.actions.sendMessage(text);
} catch {
// Error is tracked in chatAutomat state (sendError)
}
};
handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void this.handleSend();
}
};
handleAnswerTool = async ({ tool_call_id, answer }) => {
try {
await chatAutomat.actions.sendToolResponse({ tool_call_id, answer });
} catch (err) {
console.error('[ChatDrawer] Failed to send tool response:', err);
}
};
getPendingAskUser = () => selectPendingAskUser(this.state);
renderHeader() {
const {
conversations,
selectedConversationId,
sseStatus,
loadingConversations,
} = this.state;
return (
<Box
sx={{
p: 2,
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 2,
bgcolor: 'primary.main',
color: 'primary.contrastText',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
</Box>
<Typography variant="subtitle1" fontWeight={700}>
KI-Assistent
</Typography>
</Box>
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen">
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Box>
{/* Conversation Selector Bar */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControl fullWidth size="small">
<InputLabel id="chat-conversation-label">Konversation</InputLabel>
<Select
labelId="chat-conversation-label"
value={selectedConversationId || ''}
label="Konversation"
onChange={this.handleSelectConversation}
disabled={loadingConversations || conversations.length === 0}
sx={{ borderRadius: 2 }}
renderValue={(selectedId) => {
const conv = conversations.find((c) => c.id === selectedId);
if (!conv) return '';
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, overflow: 'hidden' }}>
<Typography variant="body2" fontWeight={600} noWrap>
#{conv.id}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{conv.model || 'Standard'}
</Typography>
</Box>
);
}}
>
{conversations.map((c) => (
<MenuItem key={c.id} value={c.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', overflow: 'hidden' }}>
<Typography variant="body2" fontWeight={600} noWrap>
#{c.id}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexGrow: 1 }}>
{c.model || 'Standard'}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<IconButton
size="small"
color="error"
disabled={!selectedConversationId}
onClick={(e) => this.handleDeleteConversation(e, selectedConversationId)}
aria-label="Konversation löschen"
sx={{
borderRadius: 2,
border: 1,
borderColor: 'divider',
p: 0.9,
flexShrink: 0,
}}
>
<DeleteOutlineRoundedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="primary"
disabled={!selectedConversationId}
onClick={this.handleNewChat}
aria-label="Neuer Chat"
sx={{
borderRadius: 2,
border: 1,
borderColor: 'divider',
p: 0.9,
flexShrink: 0,
}}
>
<AddRoundedIcon fontSize="small" />
</IconButton>
</Box>
</Box>
);
}
renderBody() {
const {
conversations,
selectedConversationId,
draftConversation,
conversationState,
messages,
streamingText,
loadingConversations,
loadingMessages,
model,
} = this.state;
if (loadingConversations && conversations.length === 0) {
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', p: 3, gap: 2 }}>
<CircularProgress size={32} />
<Typography variant="body2" color="text.secondary">
Lade Konversationen…
</Typography>
</Box>
);
}
if (conversations.length === 0 && !draftConversation) {
return (
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
textAlign: 'center',
gap: 2,
}}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: 'primary.light',
color: 'primary.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AutoAwesomeRoundedIcon sx={{ fontSize: 32 }} />
</Box>
<Typography variant="h6" fontWeight={700}>
Willkommen im Chat
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 280 }}>
Starte eine Unterhaltung mit dem KI-Modell, um Fragen zu stellen oder Daten abzufragen.
</Typography>
<Button
variant="contained"
startIcon={<AddRoundedIcon />}
onClick={this.handleNewChat}
sx={{ borderRadius: 2, mt: 1 }}
>
Neuen Chat starten
</Button>
</Box>
);
}
const pendingAsk = this.getPendingAskUser();
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{messages.length === 0 ? (
<Box sx={{ flexGrow: 1 }} />
) : (
<ChatFlowList
messages={
streamingText
? [
...messages,
{
id: 'streaming',
conversation_id: selectedConversationId,
role: 'assistant',
message: streamingText,
streaming: true,
},
]
: messages
}
conversationState={conversationState}
model={model}
/>
)}
{pendingAsk && (
<AskUserPrompt
askData={pendingAsk}
onAnswer={this.handleAnswerTool}
disabled={this.state.isSending}
/>
)}
</Box>
);
}
renderFooter() {
const {
conversationState,
isSending,
selectedConversationId,
draftConversation,
inputText,
sendError,
} = this.state;
const pendingAsk = this.getPendingAskUser();
const isBusy = conversationState === 'busy';
const isWorking = isBusy || isSending;
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
const canSend = !isWorking && canChat && Boolean(inputText.trim());
return (
<Box
component="form"
onSubmit={this.handleSend}
sx={{
p: 2,
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{sendError && (
<Box
sx={{
p: 1,
borderRadius: 1.5,
bgcolor: 'error.light',
color: 'error.contrastText',
fontSize: '0.75rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption">{sendError}</Typography>
<IconButton
size="small"
onClick={() => chatAutomat.setState({ sendError: null })}
sx={{ color: 'inherit', p: 0.2 }}
>
<CloseRoundedIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
<TextField
inputRef={this.inputRef}
multiline
maxRows={4}
size="small"
fullWidth
value={inputText}
onChange={(e) => this.setState({ inputText: e.target.value })}
onKeyDown={this.handleKeyDown}
disabled={!canChat}
placeholder={
pendingAsk
? 'Bitte Frage oben beantworten…'
: 'Nachricht eingeben… (Enter)'
}
slotProps={{
htmlInput: {
spellCheck: false,
autoCorrect: 'off',
autoCapitalize: 'off',
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2.5,
bgcolor: 'background.default',
fontSize: '0.875rem',
},
}}
/>
<IconButton
type="submit"
color="primary"
disabled={!canSend}
title={
isWorking
? 'Modell antwortet gerade…'
: 'Senden (Enter)'
}
sx={{
bgcolor: isWorking ? 'action.hover' : 'primary.main',
color: isWorking ? 'warning.main' : 'primary.contrastText',
width: 40,
height: 40,
borderRadius: 2.5,
'&:hover': {
bgcolor: 'primary.dark',
},
'&.Mui-disabled': {
bgcolor: isWorking ? 'action.hover' : 'primary.main',
color: isWorking ? 'warning.main' : 'primary.contrastText',
cursor: 'default',
},
}}
>
{isWorking ? (
<CircularProgress size={18} color="inherit" thickness={4.5} />
) : (
<SendRoundedIcon sx={{ fontSize: 20 }} />
)}
</IconButton>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.68rem', textAlign: 'center' }}>
Enter zum Senden • Shift+Enter für Zeilenumbruch
</Typography>
</Box>
);
}
render() {
const { isOpen, drawerWidth, isResizing } = this.state;
return (
<>
<Drawer
anchor="right"
open={isOpen}
onClose={this.handleClose}
sx={(theme) => ({
zIndex: theme.zIndex.drawer + 2,
'& .MuiDrawer-paper': {
width: { xs: '100vw', sm: `${drawerWidth}px` },
maxWidth: '100vw',
display: 'flex',
flexDirection: 'column',
boxShadow: '-8px 0 32px rgba(0, 0, 0, 0.12)',
position: 'fixed',
overflow: 'visible',
transition: isResizing ? 'none !important' : undefined,
},
})}
slotProps={{
transition: {
onEntered: () => this.focusInput(20),
},
paper: {
sx: {
width: { xs: '100vw', sm: `${drawerWidth}px` },
maxWidth: '100vw',
display: 'flex',
flexDirection: 'column',
boxShadow: '-8px 0 32px rgba(0, 0, 0, 0.12)',
position: 'fixed',
overflow: 'visible',
transition: isResizing ? 'none !important' : undefined,
},
},
}}
>
{/* Draggable Resizer Handle */}
<Box
onPointerDown={this.handleResizeStart}
onMouseDown={this.handleResizeStart}
onTouchStart={this.handleResizeStart}
onDoubleClick={this.handleResizeDoubleClick}
title="Ziehen zum Anpassen der Breite • Doppelklick zum Zurücksetzen"
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: 16,
cursor: 'col-resize',
zIndex: 9999,
display: { xs: 'none', sm: 'flex' },
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
userSelect: 'none',
'&:hover .drawer-resizer-line, &:active .drawer-resizer-line': {
bgcolor: 'primary.main',
height: 56,
width: 4,
opacity: 1,
boxShadow: '0 0 10px rgba(59, 91, 219, 0.6)',
},
}}
>
<Box
className="drawer-resizer-line"
sx={{
width: isResizing ? 4 : 3,
height: isResizing ? 56 : 36,
borderRadius: 2,
bgcolor: isResizing ? 'primary.main' : 'divider',
opacity: isResizing ? 1 : 0.6,
transition: 'background-color 0.15s, height 0.15s, width 0.15s, opacity 0.15s, box-shadow 0.15s',
boxShadow: isResizing ? '0 0 10px rgba(59, 91, 219, 0.6)' : 'none',
}}
/>
</Box>
{this.renderHeader()}
{this.renderBody()}
{this.renderFooter()}
</Drawer>
</>
);
}
}

View File

@@ -0,0 +1,220 @@
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Fab from '@mui/material/Fab';
import Badge from '@mui/material/Badge';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import ChatRoundedIcon from '@mui/icons-material/ChatRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
import chatAutomat, { selectPendingAskUser, selectHasPendingAsk } from '../../automats/chatAutomat';
export default class ChatFab extends PureComponent {
constructor(props) {
super(props);
const s = chatAutomat.state;
this.state = {
isOpen: s.isOpen,
conversationState: s.conversationState,
streamPhase: s.streamPhase,
isSending: s.isSending,
hasPendingAsk: selectHasPendingAsk(s),
pendingAsk: selectPendingAskUser(s),
sseStatus: s.sseStatus,
};
}
componentDidMount() {
this.unsub = chatAutomat.subscribe(this, (state) => ({
isOpen: state.isOpen,
conversationState: state.conversationState,
streamPhase: state.streamPhase,
isSending: state.isSending,
hasPendingAsk: selectHasPendingAsk(state),
pendingAsk: selectPendingAskUser(state),
sseStatus: state.sseStatus,
}));
}
componentWillUnmount() {
this.unsub?.();
}
handleClick = () => {
chatAutomat.actions.toggleChat();
};
render() {
const { isOpen, conversationState, streamPhase, isSending, hasPendingAsk, pendingAsk } = this.state;
const isBusy = conversationState === 'busy' || streamPhase === 'thinking' || streamPhase === 'working' || isSending;
let tooltipTitle = 'KI-Chat öffnen';
let ariaLabel = 'KI-Chat';
if (hasPendingAsk) {
tooltipTitle = pendingAsk?.question
? `Antwort erforderlich: „${pendingAsk.question}“`
: 'Antwort erforderlich: Das Modell wartet auf Ihre Eingabe';
ariaLabel = 'KI-Chat: Antwort erforderlich';
} else if (isBusy) {
tooltipTitle = 'Modell antwortet gerade…';
ariaLabel = 'KI-Chat: Modell antwortet';
}
return (
<Box
sx={(theme) => ({
position: 'fixed',
bottom: { xs: 20, sm: 28 },
right: { xs: 20, sm: 28 },
zIndex: theme.zIndex.speedDial || 1050,
opacity: isOpen ? 0 : 1,
transform: isOpen ? 'scale(0)' : 'scale(1)',
pointerEvents: isOpen ? 'none' : 'auto',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s linear',
})}
>
<Box sx={{ position: 'relative' }}>
{/* Animated pulsing halo when awaiting user answer */}
{hasPendingAsk && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -8,
left: -8,
right: -8,
bottom: -8,
borderRadius: '50%',
bgcolor: '#f59e0b',
opacity: 0.5,
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabAskPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
},
}}
/>
)}
{/* Animated pulsating halo when model is busy thinking/working */}
{!hasPendingAsk && isBusy && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -6,
left: -6,
right: -6,
bottom: -6,
borderRadius: '50%',
bgcolor: 'warning.main',
opacity: 0.4,
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
},
}}
/>
)}
{/* Circular spinner ring when model is busy */}
{!hasPendingAsk && isBusy && !isOpen && (
<CircularProgress
size={66}
thickness={2.5}
sx={{
position: 'absolute',
top: -6,
left: -6,
color: '#f59e0b',
pointerEvents: 'none',
}}
/>
)}
<Tooltip title={tooltipTitle} placement="left" arrow disableHoverListener={isOpen}>
<Badge
badgeContent={hasPendingAsk ? '?' : undefined}
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
variant={hasPendingAsk ? 'standard' : 'dot'}
invisible={!hasPendingAsk && !isBusy}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{
'& .MuiBadge-badge': hasPendingAsk
? {
bgcolor: '#ef4444',
color: '#ffffff',
fontWeight: 900,
fontSize: '0.85rem',
minWidth: 22,
height: 22,
borderRadius: '50%',
boxShadow: '0 2px 10px rgba(239, 68, 68, 0.6)',
animation: 'badgePop 1.5s ease infinite',
'@keyframes badgePop': {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.2)' },
},
}
: undefined,
}}
>
<Fab
color="primary"
aria-label={ariaLabel}
onClick={this.handleClick}
sx={(theme) => ({
width: 54,
height: 54,
boxShadow: hasPendingAsk
? '0 8px 24px rgba(245, 158, 11, 0.45)'
: isBusy
? '0 8px 24px rgba(245, 158, 11, 0.35)'
: '0 8px 24px rgba(59, 91, 219, 0.35)',
transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s',
'&:hover': {
transform: 'scale(1.06)',
},
'&:active': {
transform: 'scale(0.96)',
},
background: hasPendingAsk
? 'linear-gradient(135deg, #f59e0b 0%, #ea580c 100%)'
: isBusy
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
})}
>
{isOpen ? (
<CloseRoundedIcon sx={{ fontSize: 24 }} />
) : hasPendingAsk ? (
<HelpOutlineRoundedIcon
sx={{
fontSize: 26,
animation: 'fabWiggle 2s ease infinite',
'@keyframes fabWiggle': {
'0%, 100%': { transform: 'rotate(0deg)' },
'15%': { transform: 'rotate(-12deg)' },
'30%': { transform: 'rotate(12deg)' },
'45%': { transform: 'rotate(-8deg)' },
'60%': { transform: 'rotate(0deg)' },
},
}}
/>
) : isBusy ? (
<AutoAwesomeRoundedIcon sx={{ fontSize: 24 }} />
) : (
<ChatRoundedIcon sx={{ fontSize: 24 }} />
)}
</Fab>
</Badge>
</Tooltip>
</Box>
</Box>
);
}
}

View File

@@ -0,0 +1,381 @@
import React, { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import HealingMarkdown from './HealingMarkdown';
function formatMessageTime(isoString) {
if (!isoString) return '';
try {
const d = new Date(isoString);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
return '';
}
}
function parseMessagePayload(raw) {
if (typeof raw !== 'string') return { content: raw };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
} catch {}
return { content: raw };
}
function MessageRow({ msg, showTime = true }) {
const payload = parseMessagePayload(msg.message);
const textContent = payload.content !== undefined ? payload.content : msg.message;
const toolCalls = Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0 ? payload.tool_calls : null;
const isUser = msg.role === 'user';
const isSystem = msg.role === 'system';
const isTool = msg.role === 'tool';
const isReasoning = msg.role === "reasoning" || Boolean(payload.reasoning_content && !toolCalls && payload.content === payload.reasoning_content);
const timeStr = showTime ? formatMessageTime(msg.created_at) : '';
if (isSystem) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 0.75, px: 2 }}>
<Box
sx={{
bgcolor: 'action.hover',
color: 'text.secondary',
px: 2,
py: 0.5,
borderRadius: 4,
fontSize: '0.75rem',
fontWeight: 500,
maxWidth: '85%',
textAlign: 'center',
}}
>
{textContent}
</Box>
</Box>
);
}
if (isReasoning) {
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.5, px: 2 }}>
<Box
sx={(theme) => ({
bgcolor: 'transparent',
color: 'text.disabled',
px: 1.5,
py: 0.5,
borderRadius: 2,
fontSize: '0.75rem',
fontStyle: 'italic',
maxWidth: '85%',
borderLeft: `2px solid ${theme.palette.divider}`,
whiteSpace: 'pre-wrap',
})}
>
<Box sx={{ fontWeight: 600, fontSize: '0.68rem', mb: 0.25 }}>Gedankengang</Box>
{textContent}
</Box>
</Box>
);
}
if (toolCalls) {
const formattedCalls = toolCalls.map((tc) => {
let args = {};
try {
if (typeof tc.function?.arguments === 'string') {
args = JSON.parse(tc.function.arguments);
} else if (tc.function?.arguments) {
args = tc.function.arguments;
}
} catch {}
if (tc.function?.name === 'ask_user') {
const q = args.question || '';
return `ask_user: ${q}`;
}
const method = args.method || (tc.function?.name ? tc.function.name.toUpperCase() : 'CALL');
const path = args.path || args.url || '';
if (path) {
return `${method} ${path}`;
}
return `${tc.function?.name || 'tool'} ${JSON.stringify(args)}`;
}).join('\n');
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
<Box
sx={(theme) => ({
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
color: 'text.secondary',
px: 1.25,
py: 0.35,
borderRadius: 2,
fontSize: '0.72rem',
maxWidth: '90%',
fontFamily: 'monospace',
border: `1px solid ${theme.palette.divider}`,
display: 'inline-flex',
alignItems: 'center',
gap: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
<Box
component="span"
sx={{
fontWeight: 600,
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: 'text.disabled',
flexShrink: 0,
}}
>
Werkzeug
</Box>
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{formattedCalls}
</Box>
</Box>
</Box>
);
}
if (isTool) {
let statusText = '';
let bodyLength = null;
let resObj = null;
if (typeof textContent === 'object' && textContent !== null) {
resObj = textContent;
} else if (typeof textContent === 'string') {
try {
const parsed = JSON.parse(textContent);
if (parsed && typeof parsed === 'object') {
resObj = parsed;
}
} catch {}
}
if (resObj && ('status' in resObj || 'statusCode' in resObj || 'body' in resObj)) {
const status = resObj.status ?? resObj.statusCode ?? 200;
statusText = `Status ${status}`;
if (typeof resObj.body === 'string') {
bodyLength = resObj.body.length;
} else if (resObj.body != null) {
bodyLength = JSON.stringify(resObj.body).length;
} else if (resObj.data != null) {
bodyLength = JSON.stringify(resObj.data).length;
}
} else if (typeof textContent === 'string') {
bodyLength = textContent.length;
}
let displayText = '';
if (resObj && ('answer' in resObj || 'content' in resObj) && ('tool_call_id' in resObj) && !('status' in resObj)) {
displayText = resObj.answer || resObj.content || '';
} else {
displayText = statusText
? `${statusText}${bodyLength != null ? ` · ${bodyLength} byte` : ''}`
: (bodyLength != null ? `${bodyLength} byte` : String(textContent));
}
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
<Box
sx={(theme) => ({
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
color: 'text.secondary',
px: 1.25,
py: 0.35,
borderRadius: 2,
fontSize: '0.72rem',
maxWidth: '90%',
fontFamily: 'monospace',
border: `1px solid ${theme.palette.divider}`,
display: 'inline-flex',
alignItems: 'center',
gap: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
<Box
component="span"
sx={{
fontWeight: 600,
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: 'text.disabled',
flexShrink: 0,
}}
>
Ergebnis
</Box>
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{displayText}
</Box>
</Box>
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
px: 2,
py: 0.75,
}}
>
<Box
sx={{
maxWidth: '85%',
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
}}
>
{timeStr ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 0.3,
px: 0.5,
}}
>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.68rem' }}>
{timeStr}
</Typography>
</Box>
) : null}
<Box
sx={(theme) => ({
px: 2,
py: 1.25,
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
bgcolor: isUser
? theme.palette.primary.main
: theme.palette.mode === 'dark'
? 'background.paper'
: '#f4f6f8',
color: isUser
? theme.palette.primary.contrastText
: theme.palette.text.primary,
boxShadow: isUser
? '0 2px 8px rgba(59, 91, 219, 0.25)'
: '0 1px 4px rgba(0, 0, 0, 0.05)',
border: isUser ? 'none' : `1px solid ${theme.palette.divider}`,
wordBreak: 'break-word',
whiteSpace: isUser ? 'pre-wrap' : 'normal',
fontSize: '0.875rem',
lineHeight: 1.5,
})}
>
{isUser ? (
textContent
) : (
<HealingMarkdown text={textContent} streaming={msg.streaming} />
)}
</Box>
</Box>
</Box>
);
}
export default class ChatFlowList extends PureComponent {
containerRef = createRef();
bottomRef = createRef();
scrollTimeout = null;
componentDidMount() {
this.scrollToBottom('auto');
}
componentDidUpdate(prevProps) {
if (
this.props.messages !== prevProps.messages ||
this.props.messages?.length !== prevProps.messages?.length
) {
this.scrollToBottom('smooth');
}
}
componentWillUnmount() {
if (this.scrollTimeout) {
clearTimeout(this.scrollTimeout);
this.scrollTimeout = null;
}
}
scrollToBottom = (behavior = 'smooth') => {
const doScroll = () => {
if (this.bottomRef.current?.scrollIntoView) {
this.bottomRef.current.scrollIntoView({ behavior, block: 'end' });
}
if (this.containerRef.current) {
this.containerRef.current.scrollTop = this.containerRef.current.scrollHeight;
}
};
// Immediate frame
requestAnimationFrame(doScroll);
// Delayed fallback for font/layout reflows
if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
this.scrollTimeout = setTimeout(doScroll, 60);
};
render() {
const { messages = [] } = this.props;
if (messages.length === 0) {
return null;
}
return (
<Box
ref={this.containerRef}
sx={{
flexGrow: 1,
height: '100%',
width: '100%',
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
display: 'flex',
flexDirection: 'column',
py: 1,
gap: 0.25,
}}
>
{messages.map((msg, index) => {
const prevMsg = index > 0 ? messages[index - 1] : null;
const currTime = formatMessageTime(msg.created_at);
const prevTime = prevMsg ? formatMessageTime(prevMsg.created_at) : '';
const showTime = Boolean(currTime && currTime !== prevTime);
return (
<MessageRow
key={msg.id || index}
msg={msg}
showTime={showTime}
/>
);
})}
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
</Box>
);
}
}

View File

@@ -0,0 +1,505 @@
import React, { memo, useState } from 'react';
import Box from '@mui/material/Box';
/**
* Healing markdown renderer.
*
* Markdown arrives token by token, so at any moment the text may end inside an
* unclosed code fence, bold/italic span, inline code or half-typed link. This
* renderer is tolerant: every unterminated construct is rendered as if it had
* been closed right at the end of the text, and a half-typed link shows only
* its label. It renders React elements only (no innerHTML), so it is XSS-safe.
*
* Supported: headings, paragraphs, (nested-less) ordered/unordered lists,
* blockquotes, fenced code, horizontal rules, `code`, **bold**, *italic*,
* ~~strike~~, [links](url) and ![images](url).
*/
const SAFE_URL = /^(https?:\/\/|mailto:|\/|#)/i;
/**
* Resolve an image target to something the browser may load.
*
* Bare/relative names are artifacts of the model sandbox virtual filesystem and are
* served by `GET /api/vfs/{*path}` — so `![plot](in_vfs.png)` and
* `![plot](labels/a.png)` resolve to `/api/vfs/in_vfs.png` and `/api/vfs/labels/a.png`.
* Absolute app paths, http(s) URLs and inline data URIs are passed through unchanged.
* Anything else (javascript:, vbscript:, …) yields `null` so it is never rendered.
*
* @param {string} rawUrl
* @returns {string | null}
*/
function resolveImageUrl(rawUrl) {
const url = String(rawUrl ?? '').trim();
if (!url) return null;
if (/^data:image\//i.test(url)) return url;
if (/^https?:\/\//i.test(url) || url.startsWith('/')) return url;
// Everything else is a VFS name: strip a leading `./`, encode each segment, keep subdirs.
const segments = url
.replace(/^\.\//, '')
.split('/')
.filter((s) => s && s !== '.')
.map((s) => encodeURIComponent(s));
if (segments.length === 0 || segments.includes('..')) return null;
return `/api/vfs/${segments.join('/')}`;
}
/**
* Strip an optional markdown link title: `path "Title"` -> `path`.
* @param {string} raw
* @returns {string}
*/
function stripImageTitle(raw) {
const trimmed = raw.trim();
const m = trimmed.match(/^(\S*)\s+["'(].*$/);
return (m ? m[1] : trimmed).trim();
}
/**
* Image element that degrades to its alt text when the resource cannot be loaded
* (404 while the model is still generating the file, revoked sandbox file, …).
*/
function MarkdownImage({ src, alt }) {
const [failed, setFailed] = useState(false);
if (!src || failed) {
return <span className="md-image-fallback">{alt || '🖼'}</span>;
}
return (
<img
className="md-image"
src={src}
alt={alt || ''}
title={alt || undefined}
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
function parseInline(src, keyPrefix = 'i') {
const out = [];
let buf = '';
let i = 0;
let n = 0;
const flush = () => {
if (buf) {
out.push(buf);
buf = '';
}
};
const push = (el) => {
flush();
out.push(el);
};
const key = () => `${keyPrefix}-${n++}`;
while (i < src.length) {
const c = src[i];
if (c === '`') {
const end = src.indexOf('`', i + 1);
const code = end === -1 ? src.slice(i + 1) : src.slice(i + 1, end);
if (code) push(<code key={key()}>{code}</code>);
i = end === -1 ? src.length : end + 1;
continue;
}
if (src.startsWith('**', i) || src.startsWith('~~', i)) {
const marker = src.slice(i, i + 2);
const end = src.indexOf(marker, i + 2);
const inner = end === -1 ? src.slice(i + 2) : src.slice(i + 2, end);
const k = key();
const children = parseInline(inner, k);
push(marker === '**' ? <strong key={k}>{children}</strong> : <del key={k}>{children}</del>);
i = end === -1 ? src.length : end + 2;
continue;
}
if (c === '*' && src[i + 1] && !/\s/.test(src[i + 1])) {
let end = i + 1;
while (end < src.length && !(src[end] === '*' && src[end + 1] !== '*' && src[end - 1] !== '*')) end++;
const closed = end < src.length;
const inner = src.slice(i + 1, closed ? end : src.length);
const k = key();
push(<em key={k}>{parseInline(inner, k)}</em>);
i = closed ? end + 1 : src.length;
continue;
}
// Images must be tried before links: `![alt](url)` starts with the same `[`.
if (c === '!' && src[i + 1] === '[') {
const closeLabel = src.indexOf(']', i + 2);
if (closeLabel !== -1 && src[closeLabel + 1] === '(') {
const endUrl = src.indexOf(')', closeLabel + 2);
const alt = src.slice(i + 2, closeLabel);
const rawUrl = stripImageTitle(
endUrl === -1 ? src.slice(closeLabel + 2) : src.slice(closeLabel + 2, endUrl),
);
const k = key();
// While streaming, the URL may still be half-typed: hold the alt text back until closed.
if (endUrl !== -1) {
const resolved = resolveImageUrl(rawUrl);
if (resolved) {
push(
<MarkdownImage key={k} src={resolved} alt={alt.trim()} />,
);
i = endUrl + 1;
continue;
}
// Unsafe/unresolvable target: fall through and show the alt text only.
push(<span key={k}>{parseInline(alt, k)}</span>);
i = endUrl + 1;
continue;
}
push(<span key={k}>{parseInline(alt, k)}</span>);
i = src.length;
continue;
}
}
if (c === '[') {
const close = src.indexOf(']', i + 1);
if (close !== -1) {
const label = src.slice(i + 1, close);
if (src[close + 1] === '(') {
const endUrl = src.indexOf(')', close + 2);
const url = (endUrl === -1 ? src.slice(close + 2) : src.slice(close + 2, endUrl)).trim();
const k = key();
if (endUrl !== -1 && SAFE_URL.test(url)) {
push(
<a key={k} href={url} target="_blank" rel="noopener noreferrer">
{parseInline(label, k)}
</a>,
);
} else {
// Half-typed (or unsafe) link: show the label only.
push(<span key={k}>{parseInline(label, k)}</span>);
}
i = endUrl === -1 ? src.length : endUrl + 1;
continue;
}
}
}
buf += c;
i++;
}
flush();
return out;
}
function isTableDelimiter(line) {
const trimmed = line.trim();
if (!trimmed.includes('-')) return false;
const parts = trimmed.replace(/^\|/, '').replace(/\|$/, '').split('|');
return parts.length > 0 && parts.every((p) => /^\s*:?-+:?\s*$/.test(p));
}
function parseTableAlignments(line) {
const parts = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
return parts.map((p) => {
const s = p.trim();
const left = s.startsWith(':');
const right = s.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return 'left';
});
}
function parseTableRowCells(line) {
const trimmed = line.trim();
// Strip outer pipes if present
const content = trimmed.replace(/^\|/, '').replace(/\|$/, '');
return content.split('|').map((c) => c.trim());
}
function parseBlocks(text) {
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
const blocks = [];
let i = 0;
const isBlockStart = (l) =>
/^```/.test(l) ||
/^#{1,6}\s/.test(l) ||
/^\s*([-*+]|\d+\.)\s+/.test(l) ||
/^>/.test(l) ||
/^(-{3,}|\*{3,}|_{3,})\s*$/.test(l) ||
(l.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1]));
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue;
}
const fence = line.match(/^```\s*([\w+-]*)/);
if (fence) {
const code = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]);
i++; // closing fence (or end of stream: healed implicitly)
blocks.push({ type: 'code', lang: fence[1], text: code.join('\n') });
continue;
}
// Markdown Table check: line has '|' and next line is table delimiter or end of text during stream
if (line.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])) {
const headerCells = parseTableRowCells(line);
const alignments = parseTableAlignments(lines[i + 1]);
i += 2; // skip header and delimiter
const rows = [];
while (i < lines.length && lines[i].trim() && lines[i].includes('|') && !isBlockStart(lines[i])) {
rows.push(parseTableRowCells(lines[i]));
i++;
}
blocks.push({
type: 'table',
headers: headerCells,
alignments,
rows,
});
continue;
}
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
blocks.push({ type: 'heading', level: heading[1].length, text: heading[2] });
i++;
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
blocks.push({ type: 'hr' });
i++;
continue;
}
if (/^>/.test(line)) {
const quote = [];
while (i < lines.length && /^>/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ''));
blocks.push({ type: 'quote', text: quote.join('\n') });
continue;
}
const item = line.match(/^\s*([-*+]|\d+\.)\s+(.*)$/);
if (item) {
const ordered = /\d/.test(item[1]);
const items = [];
while (i < lines.length) {
const m = lines[i].match(/^\s*([-*+]|\d+\.)\s+(.*)$/);
if (!m || /\d/.test(m[1]) !== ordered) break;
items.push(m[2]);
i++;
}
blocks.push({ type: 'list', ordered, items });
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !(para.length && isBlockStart(lines[i]))) para.push(lines[i++]);
blocks.push({ type: 'p', text: para.join('\n') });
}
return blocks;
}
const rootSx = (theme) => ({
whiteSpace: 'normal',
'& > *:first-of-type': { mt: 0 },
'& > *:last-child': { mb: 0 },
'& p': { m: 0, mb: 1, whiteSpace: 'pre-line' },
'& h1, & h2, & h3, & h4, & h5, & h6': { m: 0, mt: 1.25, mb: 0.75, lineHeight: 1.3, fontWeight: 600 },
'& h1': { fontSize: '1.2rem' },
'& h2': { fontSize: '1.1rem' },
'& h3': { fontSize: '1rem' },
'& h4, & h5, & h6': { fontSize: '0.9rem' },
'& ul, & ol': { m: 0, mb: 1, pl: 3 },
'& li': { mb: 0.25 },
'& blockquote': {
m: 0,
mb: 1,
pl: 1.5,
borderLeft: `3px solid ${theme.palette.divider}`,
color: 'text.secondary',
whiteSpace: 'pre-line',
},
'& code': {
fontFamily: 'monospace',
fontSize: '0.82em',
px: 0.5,
borderRadius: 0.75,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
},
'& pre': {
m: 0,
mb: 1,
p: 1.25,
borderRadius: 1.5,
overflowX: 'auto',
bgcolor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.35)' : 'rgba(0,0,0,0.06)',
},
'& pre code': { p: 0, bgcolor: 'transparent', whiteSpace: 'pre' },
'& hr': { border: 0, borderTop: `1px solid ${theme.palette.divider}`, my: 1.5 },
'& a': { color: 'primary.main' },
'& .md-image': {
display: 'block',
my: 1,
maxWidth: '100%',
maxHeight: 360,
objectFit: 'contain',
borderRadius: 1.5,
border: `1px solid ${theme.palette.divider}`,
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
},
'& .md-image-fallback': {
display: 'inline-block',
px: 0.5,
borderRadius: 0.75,
fontStyle: 'italic',
color: 'text.disabled',
border: `1px dashed ${theme.palette.divider}`,
},
'& .table-container': {
width: '100%',
overflowX: 'auto',
my: 1.5,
borderRadius: 1.5,
border: `1px solid ${theme.palette.divider}`,
},
'& table': {
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.82rem',
textAlign: 'left',
},
'& th, & td': {
py: 0.75,
px: 1.25,
borderBottom: `1px solid ${theme.palette.divider}`,
borderRight: `1px solid ${theme.palette.divider}`,
'&:last-child': { borderRight: 'none' },
},
'& th': {
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.04)',
fontWeight: 600,
},
'& tr:last-child td': {
borderBottom: 'none',
},
});
function HealingMarkdown({ text, streaming = false }) {
const blocks = parseBlocks(text);
const cursor = streaming ? ' ▍' : null;
return (
<Box sx={rootSx}>
{blocks.map((b, idx) => {
const last = idx === blocks.length - 1;
const tail = last ? cursor : null;
const k = `b${idx}`;
switch (b.type) {
case 'table':
return (
<Box key={k} className="table-container">
<table>
<thead>
<tr>
{b.headers.map((h, colIdx) => (
<th
key={`${k}-th-${colIdx}`}
style={{ textAlign: b.alignments[colIdx] || 'left' }}
>
{parseInline(h, `${k}-th-${colIdx}`)}
</th>
))}
</tr>
</thead>
<tbody>
{b.rows.map((row, rowIdx) => {
const isLastRow = last && rowIdx === b.rows.length - 1;
return (
<tr key={`${k}-tr-${rowIdx}`}>
{b.headers.map((_, colIdx) => {
const isLastCell = isLastRow && colIdx === b.headers.length - 1;
const cellValue = row[colIdx] ?? '';
return (
<td
key={`${k}-td-${rowIdx}-${colIdx}`}
style={{ textAlign: b.alignments[colIdx] || 'left' }}
>
{parseInline(cellValue, `${k}-td-${rowIdx}-${colIdx}`)}
{isLastCell ? tail : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</Box>
);
case 'code':
return (
<pre key={k}>
<code>
{b.text}
{tail}
</code>
</pre>
);
case 'heading': {
const Tag = `h${b.level}`;
return (
<Tag key={k}>
{parseInline(b.text, k)}
{tail}
</Tag>
);
}
case 'hr':
return <hr key={k} />;
case 'quote':
return (
<blockquote key={k}>
{parseInline(b.text, k)}
{tail}
</blockquote>
);
case 'list': {
const Tag = b.ordered ? 'ol' : 'ul';
return (
<Tag key={k}>
{b.items.map((it, j) => (
<li key={j}>
{parseInline(it, `${k}-${j}`)}
{last && j === b.items.length - 1 ? tail : null}
</li>
))}
</Tag>
);
}
default:
return (
<p key={k}>
{parseInline(b.text, k)}
{tail}
</p>
);
}
})}
{streaming && blocks.length === 0 ? cursor : null}
</Box>
);
}
export default memo(HealingMarkdown);

View File

@@ -0,0 +1,171 @@
import { PureComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import { PRESET_MODELS } from '../../automats/chatAutomat';
export default class NewConversationDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
provider: 'openrouter',
model: PRESET_MODELS[0].value,
customModel: '',
isCustom: false,
loading: false,
error: null,
};
}
handleModelChange = (e) => {
const val = e.target.value;
if (val === '__custom__') {
this.setState({ isCustom: true });
} else {
this.setState({ model: val, isCustom: false });
}
};
handleSubmit = async (e) => {
e?.preventDefault();
const { provider, model, customModel, isCustom } = this.state;
const finalModel = isCustom ? customModel.trim() : model.trim();
if (!finalModel) {
this.setState({ error: 'Bitte ein Modell angeben.' });
return;
}
this.setState({ loading: true, error: null });
try {
await this.props.onCreate?.({
provider: provider.trim() || 'openrouter',
model: finalModel,
});
this.props.onClose?.();
} catch (err) {
this.setState({
loading: false,
error: err?.message || 'Erstellung fehlgeschlagen.',
});
}
};
render() {
const { open, onClose } = this.props;
const { provider, model, customModel, isCustom, loading, error } = this.state;
return (
<Dialog
open={open}
onClose={loading ? undefined : onClose}
maxWidth="xs"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
p: 1,
boxShadow: '0 20px 40px rgba(0,0,0,0.2)',
},
}}
>
<form onSubmit={this.handleSubmit}>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pb: 1 }}>
<Box
sx={{
width: 38,
height: 38,
borderRadius: 2,
bgcolor: 'primary.main',
color: 'primary.contrastText',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AutoAwesomeRoundedIcon fontSize="small" />
</Box>
<Typography variant="h6" fontWeight={700}>
Neuer Chat
</Typography>
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, pt: 1 }}>
<Typography variant="body2" color="text.secondary">
Wähle das Sprachmodell für die neue Konversation aus.
</Typography>
<TextField
label="Provider"
value={provider}
onChange={(e) => this.setState({ provider: e.target.value })}
fullWidth
size="small"
disabled={loading}
helperText="Standard: openrouter"
/>
<TextField
select
label="Modell Vorlage"
value={isCustom ? '__custom__' : model}
onChange={this.handleModelChange}
fullWidth
size="small"
disabled={loading}
>
{PRESET_MODELS.map((item) => (
<MenuItem key={item.value} value={item.value}>
{item.label}
</MenuItem>
))}
<MenuItem value="__custom__">Anderes Modell eingeben…</MenuItem>
</TextField>
{isCustom && (
<TextField
label="Modell-Bezeichnung"
placeholder="z. B. anthropic/claude-3.5-haiku"
value={customModel}
onChange={(e) => this.setState({ customModel: e.target.value })}
fullWidth
size="small"
autoFocus
disabled={loading}
/>
)}
{error && (
<Typography variant="caption" color="error.main" sx={{ mt: -1 }}>
{error}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, pt: 1 }}>
<Button onClick={onClose} disabled={loading} color="inherit" sx={{ borderRadius: 2 }}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={loading}
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : null}
sx={{ borderRadius: 2, px: 2.5 }}
>
Starten
</Button>
</DialogActions>
</form>
</Dialog>
);
}
}

View File

@@ -0,0 +1,4 @@
export { default as ChatFab } from './ChatFab';
export { default as ChatDrawer } from './ChatDrawer';
export { default as ChatFlowList } from './ChatFlowList';
export { default as NewConversationDialog } from './NewConversationDialog';