1639 lines
55 KiB
JavaScript
1639 lines
55 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 Tooltip from '@mui/material/Tooltip';
|
|
import Badge from '@mui/material/Badge';
|
|
import Divider from '@mui/material/Divider';
|
|
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
|
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 { setting as settingApi } from '../../api';
|
|
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
|
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
|
import vfsAutomat from '../../automats/vfsAutomat';
|
|
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
|
|
import ChatFlowList from './ChatFlowList';
|
|
import AskUserPrompt from './AskUserPrompt';
|
|
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
|
|
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
|
|
import {
|
|
BLUE_GLOW,
|
|
SHADOW_DRAWER,
|
|
} from '../../colors';
|
|
|
|
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;
|
|
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: '',
|
|
isInputFocused: false,
|
|
stagedAttachments: [],
|
|
stagedReferences: [],
|
|
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);
|
|
};
|
|
|
|
componentDidMount() {
|
|
if (this.state.isOpen) {
|
|
this.focusInput(150);
|
|
}
|
|
void this.loadModelSettings();
|
|
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,
|
|
activeTurns: state.activeTurns || [],
|
|
};
|
|
});
|
|
|
|
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,
|
|
}));
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
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);
|
|
};
|
|
|
|
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 || [];
|
|
|
|
if (
|
|
(!rawText && attachments.length === 0 && references.length === 0) ||
|
|
this.state.conversationState === 'busy' ||
|
|
this.state.isSending
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let text = rawText;
|
|
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: [],
|
|
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 });
|
|
}
|
|
};
|
|
|
|
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);
|
|
|
|
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 }} />
|
|
|
|
{/* modelslug input */}
|
|
<InputBase
|
|
value={modelslug || ''}
|
|
onChange={this.handleModelslugChange}
|
|
onBlur={this.handleModelslugBlur}
|
|
onKeyDown={this.handleModelslugKeyDown}
|
|
placeholder="Modellslug"
|
|
inputProps={{
|
|
spellCheck: false,
|
|
autoCorrect: 'off',
|
|
autoCapitalize: '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 }}>
|
|
<Tooltip title={isVfsOpen ? 'Dateien schließen' : 'Dateien parallel öffnen'}>
|
|
<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>
|
|
</Tooltip>
|
|
|
|
<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 }}
|
|
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,
|
|
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,
|
|
}}
|
|
>
|
|
<AutoAwesomeRoundedIcon sx={{ fontSize: 44, color: 'primary.main', opacity: 0.85 }} />
|
|
<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 && (!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}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
renderFooter() {
|
|
const {
|
|
conversationState,
|
|
isSending,
|
|
selectedConversationId,
|
|
draftConversation,
|
|
inputText,
|
|
isInputFocused,
|
|
sendError,
|
|
stagedAttachments,
|
|
stagedReferences,
|
|
isProcessingFiles,
|
|
isDraggingFiles,
|
|
attachmentError,
|
|
} = 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()) || stagedAttachments.length > 0 || stagedReferences.length > 0);
|
|
|
|
return (
|
|
<Box
|
|
component="form"
|
|
onSubmit={this.handleSend}
|
|
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={() => 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 && (
|
|
<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>
|
|
))}
|
|
</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}
|
|
placeholder={
|
|
pendingAsk
|
|
? 'Bitte Frage 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}
|
|
sx={(theme) => ({
|
|
zIndex: theme.zIndex.drawer + 2,
|
|
'& .MuiDrawer-paper': {
|
|
width: { xs: '100vw', sm: `${totalWidth}px` },
|
|
maxWidth: '100vw',
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
boxShadow: SHADOW_DRAWER,
|
|
position: 'fixed',
|
|
overflow: 'visible',
|
|
transition: isResizing ? 'none !important' : undefined,
|
|
},
|
|
})}
|
|
slotProps={{
|
|
transition: {
|
|
onEntered: () => this.focusInput(20),
|
|
},
|
|
paper: {
|
|
sx: {
|
|
width: { xs: '100vw', sm: `${totalWidth}px` },
|
|
maxWidth: '100vw',
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
boxShadow: SHADOW_DRAWER,
|
|
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 ${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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
}
|