2125 lines
73 KiB
JavaScript
2125 lines
73 KiB
JavaScript
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 InputBase from '@mui/material/InputBase';
|
|
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 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 DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
|
|
|
import Tooltip from '@mui/material/Tooltip';
|
|
import Badge from '@mui/material/Badge';
|
|
import Divider from '@mui/material/Divider';
|
|
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 AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
|
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
|
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
|
|
import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
|
|
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
|
|
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
|
|
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
|
|
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
|
|
import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded';
|
|
import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded';
|
|
import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded';
|
|
|
|
import { setting as settingApi } from '../../api';
|
|
import chatAutomat, {
|
|
selectPendingAskUser,
|
|
selectPendingDraftRead,
|
|
} from '../../automats/chatAutomat';
|
|
import { getActiveDraftContent } from '../../automats/draftSync';
|
|
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
|
import vfsAutomat from '../../automats/vfsAutomat';
|
|
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
|
|
import ChatFlowList from './ChatFlowList';
|
|
import AskUserPrompt from './AskUserPrompt';
|
|
import DraftRequestPrompt from './DraftRequestPrompt';
|
|
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
|
|
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
|
|
import {
|
|
BLUE_GLOW,
|
|
DIALOG_FOG,
|
|
SHADOW_DRAWER,
|
|
} from '../../colors';
|
|
|
|
const DEFAULT_DRAWER_WIDTH = 460;
|
|
const EXTRACTION_MODEL_INSTRUCTION = "use 'Process Incoming Product and Invoices' SKILL to fill drafts/delivery vfs file";
|
|
|
|
/** Sort by updated_at desc; subagents (started_by set) directly below their parent. */
|
|
function sortConversations(list) {
|
|
const ts = (c) => new Date(c.updated_at || c.created_at || 0).getTime() || 0;
|
|
const byUpdated = (a, b) => ts(b) - ts(a);
|
|
const ids = new Set(list.map((c) => c.id));
|
|
const children = new Map();
|
|
const roots = [];
|
|
for (const c of list) {
|
|
if (c.started_by != null && ids.has(c.started_by)) {
|
|
if (!children.has(c.started_by)) children.set(c.started_by, []);
|
|
children.get(c.started_by).push(c);
|
|
} else {
|
|
roots.push(c);
|
|
}
|
|
}
|
|
const out = [];
|
|
const walk = (c, depth) => {
|
|
out.push({ conv: c, depth });
|
|
(children.get(c.id) || []).sort(byUpdated).forEach((k) => walk(k, depth + 1));
|
|
};
|
|
roots.sort(byUpdated).forEach((c) => walk(c, 0));
|
|
return out;
|
|
}
|
|
|
|
function formatUpdated(value) {
|
|
if (!value) return '';
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return '';
|
|
return d.toLocaleString('de-DE', {
|
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function conversationTitle(c) {
|
|
let msg = (c.first_message || '').trim();
|
|
if (!msg) return '…';
|
|
|
|
const extMatch = msg.match(/\[EXTRACTION(?:\s+label="([^"]*)")?\]|<extraction(?:\s+label="([^"]*)")?>/i);
|
|
const hasInvoiceSkill = msg.includes("Process Incoming Product and Invoices");
|
|
|
|
let stripped = msg
|
|
.replace(/\[EXTRACTION(?:\s+label="[^"]*")?\][\s\S]*?\[\/EXTRACTION\]/gi, '')
|
|
.replace(/<extraction(?:\s+label="[^"]*")?>[\s\S]*?<\/extraction>/gi, '')
|
|
.replace(/```[\s\S]*?```/g, '')
|
|
.replace(/use 'Process Incoming Product and Invoices' SKILL to fill drafts\/delivery vfs file/gi, '')
|
|
.replace(/<USERENV>[\s\S]*?<\/USERENV>/gi, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
if (extMatch || hasInvoiceSkill) {
|
|
const label = extMatch?.[1] || extMatch?.[2] || 'parsed invoice';
|
|
if (stripped) {
|
|
return `${label}: ${stripped}`;
|
|
}
|
|
return label;
|
|
}
|
|
|
|
let clean = msg
|
|
.replace(/<USERENV>[\s\S]*?<\/USERENV>/gi, '')
|
|
.replace(/```[\s\S]*?```/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
return clean || msg.slice(0, 40) || '…';
|
|
}
|
|
const MIN_DRAWER_WIDTH = 320;
|
|
|
|
export default class ChatDrawer extends PureComponent {
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
const s = chatAutomat.state;
|
|
const settings = userSettingsAutomat.state;
|
|
const vfs = vfsAutomat.state;
|
|
this.state = {
|
|
isOpen: s.isOpen,
|
|
isVfsOpen: vfs.isOpen,
|
|
vfsWidth: vfs.vfsWidth || 480,
|
|
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,
|
|
activeTurns: s.activeTurns || [],
|
|
inputText: s.pendingInput != null ? s.pendingInput : '',
|
|
isInputFocused: false,
|
|
stagedAttachments: [],
|
|
stagedReferences: [],
|
|
stagedExtractions: s.pendingExtraction
|
|
? [
|
|
{
|
|
id: `ext-${Date.now()}`,
|
|
text: s.pendingExtraction.text,
|
|
label: s.pendingExtraction.label || 'parsed invoice',
|
|
instruction: s.pendingExtraction.instruction || EXTRACTION_MODEL_INSTRUCTION,
|
|
},
|
|
]
|
|
: [],
|
|
previewExtraction: null,
|
|
isProcessingFiles: false,
|
|
isDraggingFiles: false,
|
|
attachmentError: null,
|
|
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
|
|
isResizing: false,
|
|
showSettingsBar: false,
|
|
modelslugflag: 'floor',
|
|
modelreasoning: 'medium',
|
|
modelslug: 'openai/gpt-6-luna',
|
|
};
|
|
this.inputRef = React.createRef();
|
|
this.fileInputRef = React.createRef();
|
|
this.focusTimeout = null;
|
|
}
|
|
|
|
focusInput = (delay = 50) => {
|
|
if (this.focusTimeout) clearTimeout(this.focusTimeout);
|
|
this.focusTimeout = setTimeout(() => {
|
|
this.inputRef.current?.focus?.();
|
|
}, delay);
|
|
};
|
|
|
|
focusInputAtEnd = (delay = 50) => {
|
|
if (this.focusTimeout) clearTimeout(this.focusTimeout);
|
|
this.focusTimeout = setTimeout(() => {
|
|
const el = this.inputRef.current;
|
|
if (!el) return;
|
|
el.focus?.();
|
|
if (typeof el.setSelectionRange === 'function' && el.value) {
|
|
const len = el.value.length;
|
|
el.setSelectionRange(len, len);
|
|
}
|
|
}, delay);
|
|
};
|
|
|
|
updateGutterClass(isOpen) {
|
|
if (typeof document === 'undefined') return;
|
|
const html = document.documentElement;
|
|
if (!html) return;
|
|
if (isOpen) {
|
|
html.classList.add('df-chat-open');
|
|
} else {
|
|
html.classList.remove('df-chat-open');
|
|
}
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.updateGutterClass(this.state.isOpen);
|
|
if (this.state.isOpen) {
|
|
if (chatAutomat.state.pendingInput != null) {
|
|
this.focusInputAtEnd(0);
|
|
} else {
|
|
this.focusInput(0);
|
|
}
|
|
}
|
|
void this.loadModelSettings();
|
|
if (chatAutomat.state.pendingInput != null) {
|
|
queueMicrotask(() => {
|
|
if (chatAutomat.state.pendingInput != null) {
|
|
chatAutomat.setState({ pendingInput: null });
|
|
}
|
|
});
|
|
}
|
|
if (chatAutomat.state.pendingExtraction != null) {
|
|
queueMicrotask(() => {
|
|
if (chatAutomat.state.pendingExtraction != null) {
|
|
chatAutomat.setState({ pendingExtraction: null });
|
|
}
|
|
});
|
|
}
|
|
this.unsub = chatAutomat.subscribe(this, (state) => {
|
|
const wasOpen = this.state.isOpen;
|
|
const willOpen = state.isOpen;
|
|
const patch = {
|
|
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,
|
|
activeTurns: state.activeTurns || [],
|
|
};
|
|
if (state.pendingInput != null) {
|
|
patch.inputText = this.state.inputText
|
|
? `${this.state.inputText}\n\n${state.pendingInput}`
|
|
: state.pendingInput;
|
|
queueMicrotask(() => {
|
|
if (chatAutomat.state.pendingInput != null) {
|
|
chatAutomat.setState({ pendingInput: null });
|
|
}
|
|
});
|
|
this.focusInputAtEnd(0);
|
|
} else if (!wasOpen && willOpen) {
|
|
this.focusInput(0);
|
|
}
|
|
if (state.pendingExtraction != null) {
|
|
const ext = state.pendingExtraction;
|
|
const newExt = {
|
|
id: `ext-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
text: ext.text,
|
|
label: ext.label || 'parsed invoice',
|
|
instruction: ext.instruction || EXTRACTION_MODEL_INSTRUCTION,
|
|
};
|
|
patch.stagedExtractions = [...(this.state.stagedExtractions || []), newExt];
|
|
queueMicrotask(() => {
|
|
if (chatAutomat.state.pendingExtraction != null) {
|
|
chatAutomat.setState({ pendingExtraction: null });
|
|
}
|
|
});
|
|
this.focusInput(0);
|
|
}
|
|
return patch;
|
|
});
|
|
|
|
this.unsubSettings = userSettingsAutomat.subscribe(this, (state) => {
|
|
if (!this.state.isResizing) {
|
|
return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH };
|
|
}
|
|
return null;
|
|
});
|
|
|
|
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
|
isVfsOpen: state.isOpen,
|
|
vfsWidth: state.vfsWidth,
|
|
}));
|
|
}
|
|
|
|
componentDidUpdate(prevProps, prevState) {
|
|
if (this.state.isOpen !== prevState.isOpen) {
|
|
this.updateGutterClass(this.state.isOpen);
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.updateGutterClass(false);
|
|
if (this.focusTimeout) clearTimeout(this.focusTimeout);
|
|
this.unsub?.();
|
|
this.unsubSettings?.();
|
|
this.unsubVfs?.();
|
|
this.cleanupResizeListeners();
|
|
}
|
|
|
|
handleClose = () => {
|
|
chatAutomat.actions.closeChat();
|
|
};
|
|
|
|
handleInsertToChat = (text) => {
|
|
// If it's a file / line tag, e.g. [file.js#L10-L20](file.js#L10-L20) or [file.js](file.js)
|
|
const match =
|
|
typeof text === 'string' &&
|
|
text.trim().match(/^(!?)\[([^#\]]+)(?:#L?(\d+)(?:-L?(\d+))?)?\]\(([^)]+)\)$/);
|
|
if (match) {
|
|
const [, isImg, fileName, startLine, endLine, path] = match;
|
|
this.handleMentionReference({
|
|
fileName,
|
|
path,
|
|
startLine: startLine ? parseInt(startLine, 10) : null,
|
|
endLine: endLine ? parseInt(endLine, 10) : (startLine ? parseInt(startLine, 10) : null),
|
|
isImage: Boolean(isImg),
|
|
});
|
|
return;
|
|
}
|
|
this.setState((prev) => ({
|
|
inputText: prev.inputText ? `${prev.inputText}\n${text}` : text,
|
|
}));
|
|
this.focusInput(50);
|
|
};
|
|
|
|
handleMentionReference = (refData) => {
|
|
if (!refData || !refData.fileName) return;
|
|
const {
|
|
fileName,
|
|
path = refData.fileName,
|
|
startLine = null,
|
|
endLine = null,
|
|
selectedText = '',
|
|
isImage = false,
|
|
} = refData;
|
|
|
|
this.setState((prev) => {
|
|
const exists = prev.stagedReferences.some(
|
|
(r) => r.fileName === fileName && r.startLine === startLine && r.endLine === endLine
|
|
);
|
|
if (exists) {
|
|
return null;
|
|
}
|
|
const newRef = {
|
|
id: `ref-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
fileName,
|
|
path,
|
|
startLine,
|
|
endLine,
|
|
selectedText,
|
|
isImage,
|
|
};
|
|
return {
|
|
stagedReferences: [...prev.stagedReferences, newRef],
|
|
};
|
|
});
|
|
this.focusInput(50);
|
|
};
|
|
|
|
handleRemoveReference = (id) => {
|
|
this.setState((prev) => ({
|
|
stagedReferences: prev.stagedReferences.filter((r) => r.id !== id),
|
|
}));
|
|
this.focusInput(50);
|
|
};
|
|
|
|
handleRemoveExtraction = (id) => {
|
|
this.setState((prev) => ({
|
|
stagedExtractions: (prev.stagedExtractions || []).filter((e) => e.id !== id),
|
|
}));
|
|
this.focusInput(50);
|
|
};
|
|
|
|
handlePreviewExtraction = (ext) => {
|
|
console.log('[ChatDrawer] Clicked extraction pill:', ext);
|
|
this.setState({ previewExtraction: ext });
|
|
};
|
|
|
|
handleCloseExtractionPreview = () => {
|
|
this.setState({ previewExtraction: null });
|
|
};
|
|
|
|
handleCopyExtractionText = () => {
|
|
const text = this.state.previewExtraction?.text;
|
|
if (text && navigator?.clipboard?.writeText) {
|
|
navigator.clipboard.writeText(text);
|
|
}
|
|
};
|
|
|
|
handleVfsResizerStart = (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 startVfsWidth = this.state.vfsWidth;
|
|
const startChatWidth = this.state.drawerWidth;
|
|
const totalPairWidth = startVfsWidth + startChatWidth;
|
|
|
|
this.setState({ isResizing: true });
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
|
|
let currentChatWidth = startChatWidth;
|
|
let currentVfsWidth = startVfsWidth;
|
|
|
|
const onMove = (moveEvt) => {
|
|
moveEvt.preventDefault();
|
|
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
|
|
if (clientX === undefined) return;
|
|
|
|
const deltaX = clientX - startX;
|
|
const minVfs = 260;
|
|
const minChat = MIN_DRAWER_WIDTH; // 320
|
|
|
|
// Drag right (deltaX > 0): VFS increases, Chat decreases
|
|
// Drag left (deltaX < 0): VFS decreases, Chat increases
|
|
const clampedVfs = Math.max(minVfs, Math.min(totalPairWidth - minChat, Math.round(startVfsWidth + deltaX)));
|
|
const clampedChat = totalPairWidth - clampedVfs;
|
|
|
|
currentVfsWidth = clampedVfs;
|
|
currentChatWidth = clampedChat;
|
|
|
|
vfsAutomat.actions.setVfsWidth(clampedVfs);
|
|
this.setState({ drawerWidth: clampedChat });
|
|
};
|
|
|
|
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 });
|
|
vfsAutomat.actions.setVfsWidth(currentVfsWidth);
|
|
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth);
|
|
};
|
|
|
|
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);
|
|
};
|
|
|
|
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;
|
|
const startVfsWidth = this.state.vfsWidth;
|
|
const isVfs = this.state.isVfsOpen;
|
|
|
|
this.setState({ isResizing: true });
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
|
|
let currentChatWidth = startWidth;
|
|
let currentVfsWidth = startVfsWidth;
|
|
|
|
const onMove = (moveEvt) => {
|
|
moveEvt.preventDefault();
|
|
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
|
|
if (clientX === undefined) return;
|
|
|
|
const deltaX = startX - clientX;
|
|
|
|
if (isVfs) {
|
|
const maxVfs = Math.min(window.innerWidth - startWidth - 40, 1200);
|
|
const newVfsWidth = Math.max(260, Math.min(maxVfs, Math.round(startVfsWidth + deltaX)));
|
|
currentVfsWidth = newVfsWidth;
|
|
vfsAutomat.actions.setVfsWidth(newVfsWidth);
|
|
} else {
|
|
const maxWidth = Math.min(window.innerWidth - 40, 1400);
|
|
const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX)));
|
|
currentChatWidth = 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 });
|
|
if (isVfs) {
|
|
vfsAutomat.actions.setVfsWidth(currentVfsWidth);
|
|
} else {
|
|
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth);
|
|
}
|
|
};
|
|
|
|
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 rawText = this.state.inputText.trim();
|
|
const attachments = this.state.stagedAttachments || [];
|
|
const references = this.state.stagedReferences || [];
|
|
const extractions = this.state.stagedExtractions || [];
|
|
|
|
if (
|
|
(!rawText && attachments.length === 0 && references.length === 0 && extractions.length === 0) ||
|
|
this.state.conversationState === 'busy' ||
|
|
this.state.isSending
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let text = rawText;
|
|
if (extractions.length > 0) {
|
|
const instructions = extractions
|
|
.map((e) => e.instruction)
|
|
.filter(Boolean);
|
|
const uniqueInstructions = Array.from(new Set(instructions));
|
|
const instructionText =
|
|
uniqueInstructions.join('\n\n') || EXTRACTION_MODEL_INSTRUCTION;
|
|
|
|
const extBlocks = extractions
|
|
.map(
|
|
(ext) =>
|
|
`[EXTRACTION label="${ext.label || 'parsed invoice'}"]\n\`\`\`\n${ext.text.trim()}\n\`\`\`\n[/EXTRACTION]`
|
|
)
|
|
.join('\n\n');
|
|
|
|
if (text) {
|
|
text = `${instructionText}\n\n${text}\n\n${extBlocks}`;
|
|
} else {
|
|
text = `${instructionText}\n\n${extBlocks}`;
|
|
}
|
|
}
|
|
|
|
if (references.length > 0) {
|
|
const refMarkdown = references
|
|
.map((ref) => {
|
|
const lineRef = ref.startLine
|
|
? (ref.startLine === ref.endLine ? `L${ref.startLine}` : `L${ref.startLine}-L${ref.endLine}`)
|
|
: null;
|
|
const lineLabel = ref.startLine
|
|
? (ref.startLine === ref.endLine ? `:${ref.startLine}` : `:${ref.startLine}-${ref.endLine}`)
|
|
: '';
|
|
const link =
|
|
ref.isImage && !lineRef
|
|
? ``
|
|
: `[${ref.fileName}${lineLabel}](${ref.path || ref.fileName}${lineRef ? `#${lineRef}` : ''})`;
|
|
|
|
if (ref.selectedText && ref.selectedText.trim()) {
|
|
const ext = ref.fileName.split('.').pop() || '';
|
|
return `${link}\n\`\`\`${ext}\n${ref.selectedText.trim()}\n\`\`\``;
|
|
}
|
|
return link;
|
|
})
|
|
.join('\n\n');
|
|
|
|
text = text ? `${refMarkdown}\n\n${text}` : refMarkdown;
|
|
}
|
|
|
|
this.setState({
|
|
inputText: '',
|
|
stagedAttachments: [],
|
|
stagedReferences: [],
|
|
stagedExtractions: [],
|
|
attachmentError: null,
|
|
});
|
|
|
|
try {
|
|
await chatAutomat.actions.sendMessage(text, attachments);
|
|
} catch {
|
|
// Error is tracked in chatAutomat state (sendError)
|
|
}
|
|
};
|
|
|
|
handleOpenFileDialog = () => {
|
|
this.fileInputRef.current?.click();
|
|
};
|
|
|
|
handleFileSelect = (e) => {
|
|
const files = e.target?.files;
|
|
if (files && files.length > 0) {
|
|
void this.handleFiles(Array.from(files));
|
|
}
|
|
if (e.target) {
|
|
e.target.value = '';
|
|
}
|
|
};
|
|
|
|
handleFiles = async (fileList) => {
|
|
if (!fileList || fileList.length === 0) return;
|
|
this.setState({ isProcessingFiles: true, attachmentError: null });
|
|
|
|
const newAttachments = [];
|
|
const errors = [];
|
|
|
|
for (const file of fileList) {
|
|
try {
|
|
const att = await readFileAttachment(file);
|
|
newAttachments.push(att);
|
|
} catch (err) {
|
|
errors.push(err.message || `Datei "${file.name}" konnte nicht verarbeitet werden.`);
|
|
}
|
|
}
|
|
|
|
this.setState((prev) => ({
|
|
stagedAttachments: [...prev.stagedAttachments, ...newAttachments],
|
|
isProcessingFiles: false,
|
|
attachmentError: errors.length > 0 ? errors.join(' ') : null,
|
|
}));
|
|
this.focusInput(50);
|
|
};
|
|
|
|
handleRemoveAttachment = (idOrIdx) => {
|
|
this.setState((prev) => ({
|
|
stagedAttachments: prev.stagedAttachments.filter((a, idx) => (a.id ? a.id !== idOrIdx : idx !== idOrIdx)),
|
|
}));
|
|
};
|
|
|
|
handlePaste = (e) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items || items.length === 0) return;
|
|
|
|
const files = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
if (item.kind === 'file') {
|
|
const file = item.getAsFile();
|
|
if (file) files.push(file);
|
|
}
|
|
}
|
|
|
|
if (files.length > 0) {
|
|
e.preventDefault();
|
|
void this.handleFiles(files);
|
|
}
|
|
};
|
|
|
|
handleDragEnter = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.dataTransfer?.types?.includes('Files')) {
|
|
this.setState({ isDraggingFiles: true });
|
|
}
|
|
};
|
|
|
|
handleDragOver = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!this.state.isDraggingFiles && e.dataTransfer?.types?.includes('Files')) {
|
|
this.setState({ isDraggingFiles: true });
|
|
}
|
|
};
|
|
|
|
handleDragLeave = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.currentTarget.contains(e.relatedTarget)) return;
|
|
this.setState({ isDraggingFiles: false });
|
|
};
|
|
|
|
handleDrop = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.setState({ isDraggingFiles: false });
|
|
const files = e.dataTransfer?.files;
|
|
if (files && files.length > 0) {
|
|
void this.handleFiles(Array.from(files));
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
loadModelSettings = async () => {
|
|
try {
|
|
const data = await settingApi.list();
|
|
const settings = data?.settings || [];
|
|
const flagSetting = settings.find((s) => s.key === 'modelslugflag');
|
|
const reasoningSetting = settings.find((s) => s.key === 'modelreasoning');
|
|
const slugSetting = settings.find((s) => s.key === 'modelslug');
|
|
this.setState({
|
|
modelslugflag: flagSetting?.value ?? 'floor',
|
|
modelreasoning: reasoningSetting?.value ?? 'medium',
|
|
modelslug: slugSetting?.value ?? 'openai/gpt-6-luna',
|
|
});
|
|
} catch (err) {
|
|
console.error('[ChatDrawer] Failed to load model settings:', err);
|
|
}
|
|
};
|
|
|
|
handleToggleSettingsBar = () => {
|
|
this.setState((prev) => ({ showSettingsBar: !prev.showSettingsBar }));
|
|
};
|
|
|
|
handleSetFlag = async (nextFlag) => {
|
|
if (this.state.modelslugflag === nextFlag) return;
|
|
const current = this.state.modelslugflag;
|
|
this.setState({ modelslugflag: nextFlag });
|
|
try {
|
|
await settingApi.upsert('modelslugflag', { value: nextFlag });
|
|
} catch (err) {
|
|
console.error('[ChatDrawer] Failed to save modelslugflag:', err);
|
|
this.setState({ modelslugflag: current });
|
|
}
|
|
};
|
|
|
|
handleSetReasoning = async (nextReasoning) => {
|
|
if (this.state.modelreasoning === nextReasoning) return;
|
|
const current = this.state.modelreasoning;
|
|
this.setState({ modelreasoning: nextReasoning });
|
|
try {
|
|
await settingApi.upsert('modelreasoning', { value: nextReasoning });
|
|
} catch (err) {
|
|
console.error('[ChatDrawer] Failed to save modelreasoning:', err);
|
|
this.setState({ modelreasoning: current });
|
|
}
|
|
};
|
|
handleSetModelslug = async (nextSlug) => {
|
|
if (this.state.modelslug === nextSlug) return;
|
|
const current = this.state.modelslug;
|
|
this.setState({ modelslug: nextSlug });
|
|
try {
|
|
await settingApi.upsert('modelslug', { value: nextSlug });
|
|
} catch (err) {
|
|
console.error('[ChatDrawer] Failed to save modelslug:', err);
|
|
this.setState({ modelslug: current });
|
|
}
|
|
};
|
|
|
|
handleModelslugChange = (e) => {
|
|
this.setState({ modelslug: e.target.value });
|
|
};
|
|
|
|
handleModelslugBlur = async () => {
|
|
const { modelslug } = this.state;
|
|
const trimmed = (modelslug || '').trim();
|
|
try {
|
|
await settingApi.upsert('modelslug', { value: trimmed });
|
|
} catch (err) {
|
|
console.error('[ChatDrawer] Failed to save modelslug:', err);
|
|
}
|
|
};
|
|
|
|
handleModelslugKeyDown = (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.target.blur();
|
|
}
|
|
};
|
|
|
|
getPendingAskUser = () => selectPendingAskUser(this.state);
|
|
getPendingDraftRead = () => selectPendingDraftRead(this.state);
|
|
|
|
handleProvideDraft = async ({ toolCallId, draftKey, empty = false }) => {
|
|
try {
|
|
let content;
|
|
if (empty) {
|
|
content = { error: 'Draft not open or empty' };
|
|
} else {
|
|
const manifest = getActiveDraftContent(draftKey);
|
|
content = {
|
|
manifest: manifest || {
|
|
kind: draftKey,
|
|
mode: 'create',
|
|
lines: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
await chatAutomat.actions.sendToolResponse({
|
|
tool_call_id: toolCallId,
|
|
name: 'read_file',
|
|
content,
|
|
});
|
|
} catch (err) {
|
|
console.error('[ChatDrawer] Failed to provide draft response:', err);
|
|
}
|
|
};
|
|
|
|
renderHeader() {
|
|
const {
|
|
conversations,
|
|
selectedConversationId,
|
|
loadingConversations,
|
|
showSettingsBar,
|
|
modelslugflag,
|
|
modelreasoning,
|
|
modelslug,
|
|
isVfsOpen,
|
|
} = 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', minHeight: 34 }}>
|
|
{showSettingsBar ? (
|
|
/* Settings Bar Mode: Title goes away and reveals toggles */
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.handleToggleSettingsBar}
|
|
aria-label="Einstellungen schließen"
|
|
sx={{
|
|
p: 0.5,
|
|
color: 'primary.main',
|
|
bgcolor: 'action.selected',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<TuneRoundedIcon sx={{ fontSize: 18 }} />
|
|
</IconButton>
|
|
|
|
{/* Group 1: Speed (floor, walk/standard "", run/nitro) */}
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.3,
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
|
|
p: 0.3,
|
|
borderRadius: 2,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetFlag('floor')}
|
|
aria-label="Floor"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelslugflag === 'floor' ? 'primary.main' : 'transparent',
|
|
color: modelslugflag === 'floor' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelslugflag === 'floor' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<ElderlyRoundedIcon sx={{ fontSize: 17 }} />
|
|
</IconButton>
|
|
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetFlag('')}
|
|
aria-label="Normal"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelslugflag === '' ? 'primary.main' : 'transparent',
|
|
color: modelslugflag === '' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelslugflag === '' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<DirectionsWalkRoundedIcon sx={{ fontSize: 17 }} />
|
|
</IconButton>
|
|
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetFlag('nitro')}
|
|
aria-label="Nitro"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelslugflag === 'nitro' ? 'primary.main' : 'transparent',
|
|
color: modelslugflag === 'nitro' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelslugflag === 'nitro' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<DirectionsRunRoundedIcon sx={{ fontSize: 17 }} />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
|
|
|
|
{/* Group 2: Reasoning (none, low, medium, max) */}
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.3,
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
|
|
p: 0.3,
|
|
borderRadius: 2,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{/* none toggle (crossed out brain) */}
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetReasoning('none')}
|
|
aria-label="Reasoning None"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelreasoning === 'none' ? 'primary.main' : 'transparent',
|
|
color: modelreasoning === 'none' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelreasoning === 'none' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<PsychologyRoundedIcon sx={{ fontSize: 16 }} />
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
width: '125%',
|
|
height: '1.5px',
|
|
bgcolor: modelreasoning === 'none' ? 'primary.contrastText' : 'error.main',
|
|
transform: 'rotate(-45deg)',
|
|
borderRadius: 1,
|
|
}}
|
|
/>
|
|
</Box>
|
|
</IconButton>
|
|
|
|
{/* low toggle (smallest brain) */}
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetReasoning('low')}
|
|
aria-label="Reasoning Low"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelreasoning === 'low' ? 'primary.main' : 'transparent',
|
|
color: modelreasoning === 'low' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelreasoning === 'low' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<PsychologyRoundedIcon sx={{ fontSize: 13 }} />
|
|
</IconButton>
|
|
|
|
{/* medium toggle (medium brain) */}
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetReasoning('medium')}
|
|
aria-label="Reasoning Medium"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelreasoning === 'medium' ? 'primary.main' : 'transparent',
|
|
color: modelreasoning === 'medium' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelreasoning === 'medium' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<PsychologyRoundedIcon sx={{ fontSize: 17 }} />
|
|
</IconButton>
|
|
|
|
{/* max toggle (big brain) */}
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetReasoning('high')}
|
|
aria-label="Reasoning Max"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelreasoning === 'high' ? 'primary.main' : 'transparent',
|
|
color: modelreasoning === 'high' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelreasoning === 'high' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<PsychologyRoundedIcon sx={{ fontSize: 22 }} />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
|
|
|
|
{/* Group 3: Model preset shortcuts (moon -> luna, sun -> sol, universe -> astra) */}
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.3,
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
|
|
p: 0.3,
|
|
borderRadius: 2,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{/* moon toggle (openai/gpt-6-luna) */}
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetModelslug('openai/gpt-6-luna')}
|
|
aria-label="Luna (openai/gpt-6-luna)"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelslug === 'openai/gpt-6-luna' ? 'primary.main' : 'transparent',
|
|
color: modelslug === 'openai/gpt-6-luna' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelslug === 'openai/gpt-6-luna' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<DarkModeRoundedIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
|
|
{/* sun toggle (openai/gpt-6.1-sol) */}
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetModelslug('openai/gpt-6.1-sol')}
|
|
aria-label="Sol (openai/gpt-6.1-sol)"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelslug === 'openai/gpt-6.1-sol' ? 'primary.main' : 'transparent',
|
|
color: modelslug === 'openai/gpt-6.1-sol' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelslug === 'openai/gpt-6.1-sol' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<LightModeRoundedIcon sx={{ fontSize: 17 }} />
|
|
</IconButton>
|
|
|
|
{/* universe toggle (anthropic/claude-haiku-5.5) */}
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleSetModelslug('anthropic/claude-haiku-5.5')}
|
|
aria-label="Haiku (anthropic/claude-haiku-5.5)"
|
|
sx={{
|
|
p: 0.3,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 1.5,
|
|
bgcolor: modelslug === 'anthropic/claude-haiku-5.5' ? 'primary.main' : 'transparent',
|
|
color: modelslug === 'anthropic/claude-haiku-5.5' ? 'primary.contrastText' : 'text.secondary',
|
|
'&:hover': {
|
|
bgcolor: modelslug === 'anthropic/claude-haiku-5.5' ? 'primary.dark' : 'action.selected',
|
|
},
|
|
}}
|
|
>
|
|
<CycloneRoundedIcon sx={{ fontSize: 17 }} />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
{/* modelslug input */}
|
|
<InputBase
|
|
value={modelslug || ''}
|
|
onChange={this.handleModelslugChange}
|
|
onBlur={this.handleModelslugBlur}
|
|
onKeyDown={this.handleModelslugKeyDown}
|
|
placeholder="Modellslug"
|
|
inputProps={{
|
|
spellCheck: false,
|
|
autoCorrect: 'off',
|
|
autoCapitalize: 'off',
|
|
autoComplete: 'off',
|
|
'aria-label': 'Modellslug',
|
|
}}
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 80,
|
|
fontSize: '0.78rem',
|
|
fontFamily: 'monospace',
|
|
px: 0.8,
|
|
py: 0.2,
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
|
|
borderRadius: 1.5,
|
|
border: '1px solid transparent',
|
|
'&:focus-within': {
|
|
borderColor: 'primary.main',
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : '#fff'),
|
|
},
|
|
'& input': {
|
|
p: 0,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
/* Normal Mode: settings icon on the left end, title visible */
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.handleToggleSettingsBar}
|
|
aria-label="Modell-Einstellungen"
|
|
sx={{
|
|
p: 0.5,
|
|
color: 'text.secondary',
|
|
'&:hover': { color: 'primary.main', bgcolor: 'action.hover' },
|
|
}}
|
|
>
|
|
<TuneRoundedIcon sx={{ fontSize: 18 }} />
|
|
</IconButton>
|
|
<AutoAwesomeRoundedIcon sx={{ fontSize: 20, color: 'primary.main' }} />
|
|
<Typography variant="subtitle1" fontWeight={700}>
|
|
KI-Assistent
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 1, flexShrink: 0 }}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => vfsAutomat.actions.toggleVfs()}
|
|
aria-label="Dateien"
|
|
sx={{
|
|
p: 0.5,
|
|
color: isVfsOpen ? 'primary.main' : 'text.secondary',
|
|
bgcolor: isVfsOpen ? 'action.selected' : 'transparent',
|
|
borderRadius: 1.5,
|
|
border: '1px solid',
|
|
borderColor: isVfsOpen ? 'primary.main' : 'transparent',
|
|
'&:hover': {
|
|
color: 'primary.main',
|
|
bgcolor: 'action.hover',
|
|
},
|
|
}}
|
|
>
|
|
<FolderOpenRoundedIcon sx={{ fontSize: 19 }} />
|
|
</IconButton>
|
|
|
|
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ p: 0.5 }}>
|
|
<CloseRoundedIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
</Box>
|
|
|
|
|
|
{/* Conversation Selector Bar */}
|
|
{conversations.length > 0 && (
|
|
<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 }}
|
|
MenuProps={{
|
|
sx: {
|
|
zIndex: (theme) => theme.zIndex.modal + 20,
|
|
},
|
|
}}
|
|
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 sx={{ flexGrow: 1, minWidth: 0 }}>
|
|
{conversationTitle(conv)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexShrink: 0 }}>
|
|
{formatUpdated(conv.updated_at)}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}}
|
|
>
|
|
{sortConversations(conversations).map(({ conv: c, depth }) => (
|
|
<MenuItem key={c.id} value={c.id} sx={{ pl: 2 + depth * 2.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', overflow: 'hidden' }}>
|
|
{depth > 0 && (
|
|
<Typography variant="caption" color="text.secondary">↳</Typography>
|
|
)}
|
|
<Typography
|
|
variant="body2"
|
|
fontWeight={depth > 0 ? 400 : 600}
|
|
noWrap
|
|
sx={{ flexGrow: 1, minWidth: 0 }}
|
|
>
|
|
{conversationTitle(c)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexShrink: 0 }}>
|
|
{formatUpdated(c.updated_at)}
|
|
</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,
|
|
model,
|
|
} = this.state;
|
|
|
|
if (loadingConversations && conversations.length === 0) {
|
|
return (<></>)
|
|
}
|
|
|
|
if (conversations.length === 0 && !draftConversation) {
|
|
return (<></>)
|
|
}
|
|
|
|
const pendingAsk = this.getPendingAskUser();
|
|
const pendingDraftRead = this.getPendingDraftRead();
|
|
|
|
return (
|
|
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
|
{messages.length === 0 ? (
|
|
<Box sx={{ flexGrow: 1 }} />
|
|
) : (
|
|
<ChatFlowList
|
|
messages={
|
|
streamingText && (!this.state.activeTurns || this.state.activeTurns.length === 0)
|
|
? [
|
|
...messages,
|
|
{
|
|
id: 'streaming',
|
|
conversation_id: selectedConversationId,
|
|
role: 'assistant',
|
|
message: streamingText,
|
|
streaming: true,
|
|
},
|
|
]
|
|
: messages
|
|
}
|
|
activeTurns={this.state.activeTurns}
|
|
conversationState={conversationState}
|
|
model={model}
|
|
/>
|
|
)}
|
|
|
|
{pendingAsk && (
|
|
<AskUserPrompt
|
|
askData={pendingAsk}
|
|
onAnswer={this.handleAnswerTool}
|
|
disabled={this.state.isSending}
|
|
/>
|
|
)}
|
|
|
|
{pendingDraftRead && (
|
|
<DraftRequestPrompt
|
|
draftData={pendingDraftRead}
|
|
onProvideDraft={this.handleProvideDraft}
|
|
disabled={this.state.isSending}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
renderFooter() {
|
|
const {
|
|
conversationState,
|
|
isSending,
|
|
selectedConversationId,
|
|
draftConversation,
|
|
inputText,
|
|
isInputFocused,
|
|
sendError,
|
|
stagedAttachments,
|
|
stagedReferences,
|
|
stagedExtractions = [],
|
|
isProcessingFiles,
|
|
isDraggingFiles,
|
|
attachmentError,
|
|
} = this.state;
|
|
|
|
const pendingAsk = this.getPendingAskUser();
|
|
const pendingDraftRead = this.getPendingDraftRead();
|
|
const isBusy = conversationState === 'busy';
|
|
const isWorking = isBusy || isSending;
|
|
const canChat =
|
|
(Boolean(selectedConversationId) || draftConversation) &&
|
|
!pendingAsk &&
|
|
!pendingDraftRead;
|
|
const canSend =
|
|
!isWorking &&
|
|
canChat &&
|
|
(Boolean(inputText.trim()) ||
|
|
stagedAttachments.length > 0 ||
|
|
stagedReferences.length > 0 ||
|
|
stagedExtractions.length > 0);
|
|
|
|
return (
|
|
<Box
|
|
component="form"
|
|
onSubmit={this.handleSend}
|
|
noValidate
|
|
autoComplete="off"
|
|
onDragEnter={this.handleDragEnter}
|
|
onDragOver={this.handleDragOver}
|
|
onDragLeave={this.handleDragLeave}
|
|
onDrop={this.handleDrop}
|
|
sx={{
|
|
p: 2,
|
|
borderTop: 1,
|
|
borderColor: isDraggingFiles ? 'primary.main' : 'divider',
|
|
bgcolor: isDraggingFiles ? 'action.hover' : 'background.paper',
|
|
transition: 'border-color 0.15s, background-color 0.15s',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 1,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{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>
|
|
)}
|
|
|
|
{attachmentError && (
|
|
<Box
|
|
sx={{
|
|
p: 1,
|
|
borderRadius: 1.5,
|
|
bgcolor: 'warning.light',
|
|
color: 'warning.contrastText',
|
|
fontSize: '0.75rem',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Typography variant="caption">{attachmentError}</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.setState({ attachmentError: null })}
|
|
sx={{ color: 'inherit', p: 0.2 }}
|
|
>
|
|
<CloseRoundedIcon sx={{ fontSize: 14 }} />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Staged attachments tray */}
|
|
<ChatStagedAttachmentsTray
|
|
attachments={stagedAttachments}
|
|
onRemove={this.handleRemoveAttachment}
|
|
/>
|
|
|
|
{isProcessingFiles && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, px: 1 }}>
|
|
<CircularProgress size={14} thickness={5} />
|
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.72rem' }}>
|
|
Dateien werden eingelesen…
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{isDraggingFiles && (
|
|
<Box
|
|
sx={{
|
|
p: 1.5,
|
|
border: '2px dashed',
|
|
borderColor: 'primary.main',
|
|
borderRadius: 2,
|
|
textAlign: 'center',
|
|
bgcolor: 'action.selected',
|
|
color: 'primary.main',
|
|
fontWeight: 500,
|
|
fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML)
|
|
</Box>
|
|
)}
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
|
|
{/* Attachment Icon Button */}
|
|
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.handleOpenFileDialog}
|
|
disabled={!canChat || isWorking}
|
|
aria-label="Dateien anhängen"
|
|
sx={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 2.5,
|
|
color: stagedAttachments.length > 0 ? 'primary.main' : 'text.secondary',
|
|
bgcolor: stagedAttachments.length > 0 ? 'action.selected' : 'transparent',
|
|
border: '1px solid',
|
|
borderColor: stagedAttachments.length > 0 ? 'primary.main' : 'transparent',
|
|
flexShrink: 0,
|
|
'&:hover': {
|
|
bgcolor: 'action.hover',
|
|
color: 'primary.main',
|
|
},
|
|
}}
|
|
>
|
|
<Badge badgeContent={stagedAttachments.length} color="primary">
|
|
<AttachFileRoundedIcon sx={{ fontSize: 22 }} />
|
|
</Badge>
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
|
|
{/* Hidden File Input */}
|
|
<input
|
|
ref={this.fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept={ACCEPT_STRING}
|
|
onChange={this.handleFileSelect}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
|
|
{/* Unified Copilot-style input container with context reference pills */}
|
|
<Box
|
|
onClick={(e) => {
|
|
if (this.inputRef.current && (e.target === this.inputRef.current || this.inputRef.current.contains(e.target))) {
|
|
return;
|
|
}
|
|
this.focusInput(0);
|
|
}}
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
bgcolor: 'background.paper',
|
|
border: '1px solid',
|
|
borderColor: isInputFocused ? 'primary.main' : 'divider',
|
|
boxShadow: isInputFocused ? (theme) => `0 0 0 1px ${theme.palette.primary.main}` : 'none',
|
|
borderRadius: 2.5,
|
|
p: 0.75,
|
|
gap: 0.5,
|
|
cursor: 'text',
|
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
|
minHeight: 40,
|
|
boxSizing: 'border-box',
|
|
}}
|
|
>
|
|
{(stagedReferences.length > 0 || (stagedExtractions && stagedExtractions.length > 0)) && (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: 0.6,
|
|
alignItems: 'center',
|
|
mb: 0.25,
|
|
}}
|
|
>
|
|
{stagedReferences.map((ref) => (
|
|
<Box
|
|
key={ref.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
vfsAutomat.actions.openVfs();
|
|
vfsAutomat.actions.selectFile(ref.fileName);
|
|
}}
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.6,
|
|
py: 0.2,
|
|
pl: 0.7,
|
|
pr: 0.4,
|
|
borderRadius: 1.5,
|
|
bgcolor: (theme) =>
|
|
theme.palette.mode === 'dark'
|
|
? 'rgba(255, 255, 255, 0.08)'
|
|
: 'rgba(0, 0, 0, 0.05)',
|
|
border: '1px solid',
|
|
borderColor: (theme) =>
|
|
theme.palette.mode === 'dark'
|
|
? 'rgba(255, 255, 255, 0.12)'
|
|
: 'rgba(0, 0, 0, 0.1)',
|
|
color: 'text.primary',
|
|
fontSize: '0.75rem',
|
|
lineHeight: 1.2,
|
|
userSelect: 'none',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.15s ease',
|
|
'&:hover': {
|
|
bgcolor: (theme) =>
|
|
theme.palette.mode === 'dark'
|
|
? 'rgba(255, 255, 255, 0.14)'
|
|
: 'rgba(0, 0, 0, 0.09)',
|
|
borderColor: 'primary.light',
|
|
},
|
|
}}
|
|
>
|
|
<VfsFileIcon fileName={ref.fileName} sx={{ fontSize: 14 }} />
|
|
<Typography
|
|
component="span"
|
|
sx={{
|
|
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
|
|
fontSize: '0.74rem',
|
|
fontWeight: 600,
|
|
letterSpacing: '-0.2px',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
{ref.fileName}
|
|
{ref.startLine && (
|
|
<Box
|
|
component="span"
|
|
sx={{
|
|
color: 'primary.main',
|
|
fontWeight: 700,
|
|
ml: 0.3,
|
|
}}
|
|
>
|
|
:{ref.startLine === ref.endLine ? ref.startLine : `${ref.startLine}-${ref.endLine}`}
|
|
</Box>
|
|
)}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
this.handleRemoveReference(ref.id);
|
|
}}
|
|
aria-label="Referenz entfernen"
|
|
sx={{
|
|
p: 0.2,
|
|
ml: 0.1,
|
|
color: 'text.secondary',
|
|
'&:hover': {
|
|
color: 'error.main',
|
|
bgcolor: 'action.hover',
|
|
},
|
|
}}
|
|
>
|
|
<CloseRoundedIcon sx={{ fontSize: 13 }} />
|
|
</IconButton>
|
|
</Box>
|
|
))}
|
|
{stagedExtractions && stagedExtractions.map((ext) => (
|
|
<Box
|
|
key={ext.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
this.handlePreviewExtraction(ext);
|
|
}}
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.6,
|
|
py: 0.2,
|
|
pl: 0.7,
|
|
pr: 0.4,
|
|
borderRadius: 1.5,
|
|
bgcolor: (theme) =>
|
|
theme.palette.mode === 'dark'
|
|
? 'rgba(255, 255, 255, 0.08)'
|
|
: 'rgba(0, 0, 0, 0.05)',
|
|
border: '1px solid',
|
|
borderColor: (theme) =>
|
|
theme.palette.mode === 'dark'
|
|
? 'rgba(255, 255, 255, 0.12)'
|
|
: 'rgba(0, 0, 0, 0.1)',
|
|
color: 'text.primary',
|
|
fontSize: '0.75rem',
|
|
lineHeight: 1.2,
|
|
userSelect: 'none',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.15s ease',
|
|
'&:hover': {
|
|
bgcolor: (theme) =>
|
|
theme.palette.mode === 'dark'
|
|
? 'rgba(255, 255, 255, 0.14)'
|
|
: 'rgba(0, 0, 0, 0.09)',
|
|
borderColor: 'primary.light',
|
|
},
|
|
}}
|
|
>
|
|
<DescriptionOutlinedIcon sx={{ fontSize: 14, color: 'primary.main' }} />
|
|
<Typography
|
|
component="span"
|
|
sx={{
|
|
fontSize: '0.74rem',
|
|
fontWeight: 600,
|
|
letterSpacing: '-0.2px',
|
|
}}
|
|
>
|
|
{ext.label || 'parsed invoice'}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
this.handleRemoveExtraction(ext.id);
|
|
}}
|
|
aria-label="Entfernen"
|
|
sx={{
|
|
p: 0.2,
|
|
ml: 0.1,
|
|
color: 'text.secondary',
|
|
'&:hover': {
|
|
color: 'error.main',
|
|
bgcolor: 'action.hover',
|
|
},
|
|
}}
|
|
>
|
|
<CloseRoundedIcon sx={{ fontSize: 13 }} />
|
|
</IconButton>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
<InputBase
|
|
inputRef={this.inputRef}
|
|
multiline
|
|
minRows={1}
|
|
maxRows={5}
|
|
fullWidth
|
|
value={inputText}
|
|
onChange={(e) => this.setState({ inputText: e.target.value })}
|
|
onKeyDown={this.handleKeyDown}
|
|
onPaste={this.handlePaste}
|
|
onFocus={() => this.setState({ isInputFocused: true })}
|
|
onBlur={() => this.setState({ isInputFocused: false })}
|
|
disabled={!canChat}
|
|
inputProps={{
|
|
spellCheck: false,
|
|
autoCorrect: 'off',
|
|
autoCapitalize: 'off',
|
|
autoComplete: 'off',
|
|
}}
|
|
placeholder={
|
|
pendingAsk
|
|
? 'Bitte Frage oben beantworten…'
|
|
: pendingDraftRead
|
|
? 'Bitte Entwurfsanfrage oben beantworten…'
|
|
: stagedAttachments.length > 0
|
|
? 'Nachricht hinzufügen oder direkt absenden…'
|
|
: undefined
|
|
}
|
|
sx={{
|
|
px: 0.5,
|
|
py: 0.1,
|
|
fontSize: '0.875rem',
|
|
lineHeight: 1.45,
|
|
'& .MuiInputBase-input': {
|
|
p: 0,
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
<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,
|
|
flexShrink: 0,
|
|
'&: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>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const { isOpen, drawerWidth, isResizing, isVfsOpen, vfsWidth } = this.state;
|
|
const totalWidth = isVfsOpen ? drawerWidth + vfsWidth : drawerWidth;
|
|
|
|
return (
|
|
<>
|
|
<Drawer
|
|
anchor="right"
|
|
open={isOpen}
|
|
onClose={this.handleClose}
|
|
disableScrollLock
|
|
transitionDuration={0}
|
|
ModalProps={{
|
|
disableScrollLock: true,
|
|
disableEnforceFocus: true,
|
|
}}
|
|
sx={(theme) => ({
|
|
zIndex: theme.zIndex.modal + 10,
|
|
'& .MuiBackdrop-root': {
|
|
backgroundColor: DIALOG_FOG,
|
|
transition: 'none !important',
|
|
},
|
|
'& .MuiDrawer-paper': {
|
|
width: { xs: '100vw', sm: `${totalWidth}px` },
|
|
maxWidth: '100vw',
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
boxShadow: SHADOW_DRAWER,
|
|
position: 'fixed',
|
|
overflow: 'visible',
|
|
transition: 'none !important',
|
|
},
|
|
})}
|
|
slotProps={{
|
|
transition: {
|
|
timeout: 0,
|
|
onEntered: () => this.focusInput(0),
|
|
},
|
|
paper: {
|
|
sx: {
|
|
width: { xs: '100vw', sm: `${totalWidth}px` },
|
|
maxWidth: '100vw',
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
boxShadow: SHADOW_DRAWER,
|
|
position: 'fixed',
|
|
overflow: 'visible',
|
|
transition: 'none !important',
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
{/* 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 ${BLUE_GLOW}`,
|
|
},
|
|
}}
|
|
>
|
|
<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 ${BLUE_GLOW}` : 'none',
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Left: VFS Filesystem Editor Panel (parallel to chat) */}
|
|
{isVfsOpen && (
|
|
<Box
|
|
sx={{
|
|
width: { xs: '100%', sm: `${vfsWidth}px` },
|
|
minWidth: { sm: 340 },
|
|
maxWidth: { sm: 'calc(100vw - 320px)' },
|
|
height: '100%',
|
|
flexShrink: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<VfsEditorPanel
|
|
onInsertToChat={this.handleInsertToChat}
|
|
onMentionReference={this.handleMentionReference}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Vertical Split Resizer between VFS and Chat (0px width so no layout shift or grey bar) */}
|
|
{isVfsOpen && (
|
|
<Box
|
|
onPointerDown={this.handleVfsResizerStart}
|
|
onMouseDown={this.handleVfsResizerStart}
|
|
sx={{
|
|
width: 0,
|
|
position: 'relative',
|
|
flexShrink: 0,
|
|
zIndex: 10,
|
|
display: { xs: 'none', sm: 'flex' },
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'col-resize',
|
|
touchAction: 'none',
|
|
userSelect: 'none',
|
|
'&:hover .vfs-resizer-line, &:active .vfs-resizer-line': {
|
|
bgcolor: 'primary.main',
|
|
opacity: 1,
|
|
height: 48,
|
|
width: 4,
|
|
boxShadow: `0 0 8px ${BLUE_GLOW}`,
|
|
},
|
|
}}
|
|
>
|
|
{/* Invisible wide hit area (16px wide, centered on the 1px border) */}
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
left: -8,
|
|
width: 16,
|
|
cursor: 'col-resize',
|
|
}}
|
|
/>
|
|
{/* Visual handle indicator on hover / active */}
|
|
<Box
|
|
className="vfs-resizer-line"
|
|
sx={{
|
|
position: 'absolute',
|
|
width: 2,
|
|
height: 32,
|
|
borderRadius: 1,
|
|
bgcolor: 'divider',
|
|
opacity: 0.5,
|
|
transition: 'background-color 0.15s, height 0.15s, width 0.15s, opacity 0.15s, box-shadow 0.15s',
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Right: Chat Panel */}
|
|
<Box
|
|
sx={{
|
|
width: isVfsOpen ? { xs: '100%', sm: `${drawerWidth}px` } : '100%',
|
|
flexGrow: 1,
|
|
minWidth: { sm: 320 },
|
|
display: isVfsOpen ? { xs: 'none', sm: 'flex' } : 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
overflow: 'hidden',
|
|
bgcolor: 'background.paper',
|
|
}}
|
|
>
|
|
{this.renderHeader()}
|
|
{this.renderBody()}
|
|
{this.renderFooter()}
|
|
</Box>
|
|
</Drawer>
|
|
|
|
{/* Extraction Preview Dialog */}
|
|
<Dialog
|
|
open={Boolean(this.state.previewExtraction)}
|
|
onClose={this.handleCloseExtractionPreview}
|
|
maxWidth="md"
|
|
fullWidth
|
|
sx={(theme) => ({
|
|
zIndex: theme.zIndex.modal + 50,
|
|
})}
|
|
slotProps={{
|
|
paper: {
|
|
sx: {
|
|
borderRadius: 2.5,
|
|
bgcolor: 'background.paper',
|
|
backgroundImage: 'none',
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<DescriptionOutlinedIcon sx={{ color: 'primary.main', fontSize: 22 }} />
|
|
<Typography variant="h6" component="span" sx={{ fontSize: '1.05rem', fontWeight: 600 }}>
|
|
{this.state.previewExtraction?.label || 'parsed invoice'}
|
|
</Typography>
|
|
</Box>
|
|
<IconButton size="small" onClick={this.handleCloseExtractionPreview} aria-label="Schließen">
|
|
<CloseRoundedIcon sx={{ fontSize: 20 }} />
|
|
</IconButton>
|
|
</DialogTitle>
|
|
<DialogContent dividers sx={{ p: 2, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}>
|
|
<Box
|
|
component="pre"
|
|
sx={{
|
|
m: 0,
|
|
p: 1.5,
|
|
borderRadius: 1.5,
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.8rem',
|
|
lineHeight: 1.5,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
overflow: 'auto',
|
|
maxHeight: '60vh',
|
|
bgcolor: 'background.paper',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
{this.state.previewExtraction?.text || ''}
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
|
|
<Button
|
|
startIcon={<ContentCopyRoundedIcon sx={{ fontSize: 18 }} />}
|
|
onClick={this.handleCopyExtractionText}
|
|
color="primary"
|
|
variant="outlined"
|
|
size="small"
|
|
>
|
|
Kopieren
|
|
</Button>
|
|
<Button onClick={this.handleCloseExtractionPreview} color="inherit" size="small">
|
|
Schließen
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
}
|