diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx index 717c7ef..fc59abd 100644 --- a/src/components/chat/ChatDrawer.jsx +++ b/src/components/chat/ChatDrawer.jsx @@ -47,6 +47,8 @@ import ChatFlowList from './ChatFlowList'; import AskUserPrompt from './AskUserPrompt'; import { ACCEPT_STRING, readFileAttachment } from './chatAttachments'; import { ChatStagedAttachmentsTray } from './ChatAttachmentItems'; +import ChatMentionAutocomplete from './ChatMentionAutocomplete'; +import { getToolMentionToken, getFileMentionToken, searchTools } from './chatTools.jsx'; import { BLUE_GLOW, DIALOG_FOG, @@ -172,6 +174,12 @@ export default class ChatDrawer extends PureComponent { modelslugflag: 'floor', modelreasoning: 'medium', modelslug: 'openai/gpt-6-luna', + vfsFiles: vfs.files || [], + mentionOpen: false, + mentionType: null, + mentionQuery: '', + mentionStartPos: 0, + mentionSelectedIndex: 0, }; this.inputRef = React.createRef(); this.fileInputRef = React.createRef(); @@ -295,6 +303,7 @@ export default class ChatDrawer extends PureComponent { this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({ isVfsOpen: state.isOpen, vfsWidth: state.vfsWidth, + vfsFiles: state.files, })); } @@ -641,6 +650,10 @@ export default class ChatDrawer extends PureComponent { stagedReferences: [], stagedExtractions: [], attachmentError: null, + mentionOpen: false, + mentionType: null, + mentionQuery: '', + mentionSelectedIndex: 0, }); try { @@ -746,7 +759,140 @@ export default class ChatDrawer extends PureComponent { } }; + updateMentionState = (value, cursorPos) => { + const textBeforeCursor = value.slice(0, cursorPos); + const mentionMatch = textBeforeCursor.match(/(?:^|\s)([@§#])(?:\[([^\]]*)|\"([^\"]*)|([^\s@§#]*))$/); + + if (mentionMatch) { + const symbol = mentionMatch[1]; + const query = mentionMatch[2] ?? mentionMatch[3] ?? mentionMatch[4] ?? ''; + const symbolIndex = textBeforeCursor.lastIndexOf(symbol); + const isFile = symbol === '@'; + const isTool = symbol === '§' || symbol === '#'; + + if (isFile && (!this.state.vfsFiles || this.state.vfsFiles.length === 0)) { + void vfsAutomat.actions.loadFiles(); + } + + this.setState({ + inputText: value, + mentionOpen: true, + mentionType: isFile ? 'file' : isTool ? 'tool' : null, + mentionQuery: query, + mentionStartPos: symbolIndex, + mentionSelectedIndex: 0, + }); + } else { + this.setState({ + inputText: value, + mentionOpen: false, + mentionType: null, + mentionQuery: '', + mentionSelectedIndex: 0, + }); + } + }; + + handleInputChange = (e) => { + const value = e.target.value; + const cursorPos = e.target.selectionStart ?? value.length; + this.updateMentionState(value, cursorPos); + }; + + handleInputSelect = (e) => { + const cursorPos = e.target.selectionStart; + if (cursorPos != null && this.state.mentionOpen) { + this.updateMentionState(this.state.inputText, cursorPos); + } + }; + + handleMentionSelect = (item) => { + if (!item) return; + const { inputText, mentionType, mentionStartPos } = this.state; + const inputEl = this.inputRef.current; + const cursorPos = inputEl?.selectionStart ?? inputText.length; + + let token = ''; + if (mentionType === 'tool') { + token = getToolMentionToken(item); + } else if (mentionType === 'file') { + token = getFileMentionToken(item.name || item); + } + + const before = inputText.slice(0, mentionStartPos); + const after = inputText.slice(cursorPos); + const replacement = `${token} `; + const newText = `${before}${replacement}${after}`; + const newCursorPos = before.length + replacement.length; + + this.setState( + { + inputText: newText, + mentionOpen: false, + mentionType: null, + mentionQuery: '', + mentionSelectedIndex: 0, + }, + () => { + if (inputEl) { + inputEl.focus(); + try { + inputEl.selectionStart = newCursorPos; + inputEl.selectionEnd = newCursorPos; + } catch {} + } + } + ); + }; + handleKeyDown = (e) => { + const { mentionOpen, mentionType, mentionQuery, mentionSelectedIndex, vfsFiles } = this.state; + + if (mentionOpen && mentionType) { + let items = []; + if (mentionType === 'tool') { + items = searchTools(mentionQuery); + } else { + const q = (mentionQuery || '').toLowerCase().trim(); + items = (vfsFiles || []).filter((f) => !q || (f.name || '').toLowerCase().includes(q)); + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (items.length > 0) { + this.setState({ + mentionSelectedIndex: (mentionSelectedIndex + 1) % items.length, + }); + } + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (items.length > 0) { + this.setState({ + mentionSelectedIndex: (mentionSelectedIndex - 1 + items.length) % items.length, + }); + } + return; + } + + if (e.key === 'Enter' || e.key === 'Tab') { + if (items.length > 0) { + e.preventDefault(); + const selected = items[mentionSelectedIndex] || items[0]; + this.handleMentionSelect(selected); + return; + } + } + + if (e.key === 'Escape') { + e.preventDefault(); + this.setState({ mentionOpen: false, mentionType: null }); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void this.handleSend(); @@ -1507,7 +1653,18 @@ export default class ChatDrawer extends PureComponent { )} - + + this.setState({ mentionSelectedIndex: idx })} + onClose={() => this.setState({ mentionOpen: false, mentionType: null })} + /> + {/* Attachment Icon Button */} @@ -1752,11 +1909,16 @@ export default class ChatDrawer extends PureComponent { maxRows={5} fullWidth value={inputText} - onChange={(e) => this.setState({ inputText: e.target.value })} + onChange={this.handleInputChange} + onSelect={this.handleInputSelect} onKeyDown={this.handleKeyDown} onPaste={this.handlePaste} onFocus={() => this.setState({ isInputFocused: true })} - onBlur={() => this.setState({ isInputFocused: false })} + onBlur={() => { + setTimeout(() => { + this.setState({ isInputFocused: false }); + }, 200); + }} disabled={!canChat} inputProps={{ spellCheck: false, @@ -1769,7 +1931,7 @@ export default class ChatDrawer extends PureComponent { ? 'Bitte Frage oben beantworten…' : stagedAttachments.length > 0 ? 'Nachricht hinzufügen oder direkt absenden…' - : undefined + : '@ Datei · § Werkzeug erwähnen…' } sx={{ px: 0.5, diff --git a/src/components/chat/ChatFlowList.jsx b/src/components/chat/ChatFlowList.jsx index ee3dfb6..69589cf 100644 --- a/src/components/chat/ChatFlowList.jsx +++ b/src/components/chat/ChatFlowList.jsx @@ -44,66 +44,14 @@ import { SHADOW_BUBBLE_USER, SURFACE_RAISED, } from '../../colors'; +import { + TOOL_ICON_MAP, + TOOL_GERMAN_NAMES, + GoogleFaviconIcon, +} from './chatTools.jsx'; +import { InlineMentionText } from './ChatMentionPill'; -class GoogleFaviconIcon extends PureComponent { - state = { hasError: false }; - - handleError = () => { - this.setState({ hasError: true }); - }; - - render() { - const { sx } = this.props; - if (this.state.hasError) { - return ; - } - return ( - - ); - } -} - -const TOOL_ICON_MAP = { - find_files: SearchRoundedIcon, - javascript_execute: ComputerRoundedIcon, - search_images: ImageSearchRoundedIcon, - analyze_image: CompareRoundedIcon, - apisuggestionsorissues: SupportIcon, - ask_user: RecordVoiceOverIcon, - dashback_request: PhonelinkIcon, - patch: SaveAsIcon, - read_file: DescriptionIcon, - reset_vfs: DeleteForeverIcon, - rm: DeleteIcon, - web_search: GoogleFaviconIcon, - url_get: WebIcon, - subagent: EngineeringIcon, - sub_agent: EngineeringIcon, - 'sub-agent': EngineeringIcon, - pdf_read: PictureAsPdfIcon, - search_articles: ManageSearchIcon, - edit_image: LensBlurIcon, - net_gross_calc: CalculateIcon, - ean_checksum: QrCodeScannerIcon, - 'set-user-route': DirectionsIcon, - set_user_route: DirectionsIcon, - draft_delivery_manifest: EditNoteIcon, - 'draft-delivery-manifest': EditNoteIcon, -}; +export { TOOL_ICON_MAP, TOOL_GERMAN_NAMES, GoogleFaviconIcon }; function formatMessageTime(isoString) { if (!isoString) return ''; @@ -1155,7 +1103,7 @@ function MessageRow({ msg, showTime = true }) { {cleanText ? ( isUser ? ( - cleanText + ) : ( ) diff --git a/src/components/chat/ChatMentionAutocomplete.jsx b/src/components/chat/ChatMentionAutocomplete.jsx new file mode 100644 index 0000000..13f4195 --- /dev/null +++ b/src/components/chat/ChatMentionAutocomplete.jsx @@ -0,0 +1,341 @@ +import React, { PureComponent, createRef } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; +import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; +import AutoFixHighRoundedIcon from '@mui/icons-material/AutoFixHighRounded'; +import { VfsFileIcon } from '../vfs'; +import { formatVfsSize } from '../vfs/vfsUtils'; +import { searchTools } from './chatTools.jsx'; + +export default class ChatMentionAutocomplete extends PureComponent { + listRef = createRef(); + itemRefs = []; + + componentDidUpdate(prevProps) { + if (prevProps.selectedIndex !== this.props.selectedIndex) { + const activeEl = this.itemRefs[this.props.selectedIndex]; + if (activeEl?.scrollIntoView) { + activeEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + } + + render() { + const { + open, + type, + query = '', + files = [], + selectedIndex = 0, + onSelect, + onHoverIndex, + } = this.props; + + if (!open || !type) return null; + + let items = []; + if (type === 'tool') { + items = searchTools(query); + } else if (type === 'file') { + const q = (query || '').toLowerCase().trim(); + items = (files || []).filter((f) => { + if (!q) return true; + return (f.name || '').toLowerCase().includes(q); + }); + } + + this.itemRefs = []; + + return ( + + theme.palette.mode === 'dark' + ? 'rgba(26, 29, 36, 0.96)' + : 'rgba(255, 255, 255, 0.98)', + backdropFilter: 'blur(16px)', + border: '1px solid', + borderColor: (theme) => + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.14)' + : 'rgba(0, 0, 0, 0.12)', + boxShadow: (theme) => + theme.palette.mode === 'dark' + ? '0 12px 36px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255,255,255,0.05)' + : '0 12px 36px rgba(0, 0, 0, 0.18), 0 0 0 1px rgba(0,0,0,0.04)', + }} + > + {/* Header bar */} + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.04)' + : 'rgba(0, 0, 0, 0.03)', + borderBottom: '1px solid', + borderColor: 'divider', + }} + > + {type === 'tool' ? ( + + ) : ( + + )} + + + {type === 'tool' ? 'Werkzeuge & Aktionen' : 'VFS-Dateien'} + + + + ({items.length}) + + + + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.08)' + : 'rgba(0, 0, 0, 0.06)', + px: 0.8, + py: 0.2, + borderRadius: 1, + fontFamily: 'monospace', + fontWeight: 600, + }} + > + {type === 'tool' ? '§ Werkzeug' : '@ Datei'} + + + + + {/* Scrollable list of items */} + + {items.length === 0 ? ( + + + Keine passenden {type === 'tool' ? 'Werkzeuge' : 'Dateien'} gefunden + + + ) : ( + items.map((item, index) => { + const isSelected = index === selectedIndex; + const ToolIcon = type === 'tool' ? item.icon : null; + + return ( + (this.itemRefs[index] = el)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onSelect?.(item); + }} + onMouseEnter={() => onHoverIndex?.(index)} + sx={{ + px: 1.5, + py: 0.7, + mx: 0.6, + my: 0.2, + borderRadius: 1.5, + display: 'flex', + alignItems: 'center', + gap: 1.2, + cursor: 'pointer', + userSelect: 'none', + bgcolor: isSelected + ? (theme) => + theme.palette.mode === 'dark' + ? 'rgba(144, 202, 249, 0.16)' + : 'rgba(25, 118, 210, 0.1)' + : 'transparent', + border: '1px solid', + borderColor: isSelected + ? (theme) => + theme.palette.mode === 'dark' + ? 'rgba(144, 202, 249, 0.35)' + : 'rgba(25, 118, 210, 0.3)' + : 'transparent', + transition: 'all 0.1s ease', + '&:hover': { + bgcolor: (theme) => + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.08)' + : 'rgba(0, 0, 0, 0.05)', + }, + }} + > + {/* Icon */} + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.06)' + : 'rgba(0, 0, 0, 0.04)', + }} + > + {type === 'tool' ? ( + ToolIcon ? ( + + ) : ( + + ) + ) : ( + + )} + + + {/* Main Label and Details */} + + + + {type === 'tool' ? item.label : item.name} + + + + {type === 'tool' && ( + + §{item.name} + + )} + + + {/* Right pill / size */} + {type === 'file' && item.size != null && ( + + {formatVfsSize(item.size)} + + )} + + {type === 'tool' && ( + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.06)' + : 'rgba(0, 0, 0, 0.04)', + px: 0.8, + py: 0.2, + borderRadius: 1, + flexShrink: 0, + }} + > + Werkzeug + + )} + + ); + }) + )} + + + {/* Footer shortcuts */} + + theme.palette.mode === 'dark' + ? 'rgba(0, 0, 0, 0.2)' + : 'rgba(0, 0, 0, 0.02)', + borderTop: '1px solid', + borderColor: 'divider', + fontSize: '0.67rem', + color: 'text.secondary', + }} + > + + ↑↓ Navigieren + + + ↵ / Tab Auswählen + + + Esc Abbrechen + + + + ); + } +} diff --git a/src/components/chat/ChatMentionPill.jsx b/src/components/chat/ChatMentionPill.jsx new file mode 100644 index 0000000..c378277 --- /dev/null +++ b/src/components/chat/ChatMentionPill.jsx @@ -0,0 +1,333 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import { VfsFileIcon } from '../vfs'; +import vfsAutomat from '../../automats/vfsAutomat'; +import { getToolMeta } from './chatTools.jsx'; + +/** + * Inline pill for tool mentions in chat messages. + */ +export class ToolMentionPill extends PureComponent { + render() { + const { name, label, isUser, sx } = this.props; + const meta = getToolMeta(name || label); + const displayLabel = label || meta?.label || name; + const ToolIcon = meta?.icon; + + return ( + + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.08)' + : 'rgba(0, 0, 0, 0.06)', + color: isUser + ? 'inherit' + : 'text.primary', + border: '1px solid', + borderColor: isUser + ? 'rgba(255, 255, 255, 0.35)' + : (theme) => + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.14)' + : 'rgba(0, 0, 0, 0.12)', + boxShadow: isUser ? 'none' : '0 1px 2px rgba(0,0,0,0.04)', + transition: 'all 0.15s ease', + '&:hover': { + bgcolor: isUser + ? 'rgba(255, 255, 255, 0.32)' + : (theme) => + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.13)' + : 'rgba(0, 0, 0, 0.1)', + borderColor: isUser + ? 'rgba(255, 255, 255, 0.5)' + : 'primary.main', + }, + ...sx, + }} + > + {ToolIcon && ( + + )} + + §{displayLabel} + + + + ); + } +} + +/** + * Inline pill for VFS file mentions in chat messages. + * Clicking opens the file in VFS editor/preview. + */ +export class FileMentionPill extends PureComponent { + handleClick = (e) => { + e.stopPropagation(); + const { path, fileName } = this.props; + const target = path || fileName; + if (target) { + vfsAutomat.actions.openVfs(); + void vfsAutomat.actions.selectFile(target); + } + }; + + render() { + const { fileName, path, isUser, sx } = this.props; + const target = path || fileName || ''; + const displayName = fileName || target.split('/').pop() || target; + + return ( + + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.08)' + : 'rgba(0, 0, 0, 0.06)', + color: isUser + ? 'inherit' + : 'text.primary', + border: '1px solid', + borderColor: isUser + ? 'rgba(255, 255, 255, 0.35)' + : (theme) => + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.14)' + : 'rgba(0, 0, 0, 0.12)', + boxShadow: isUser ? 'none' : '0 1px 2px rgba(0,0,0,0.04)', + transition: 'all 0.15s ease', + '&:hover': { + bgcolor: isUser + ? 'rgba(255, 255, 255, 0.32)' + : (theme) => + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.13)' + : 'rgba(0, 0, 0, 0.1)', + borderColor: isUser + ? 'rgba(255, 255, 255, 0.5)' + : 'primary.main', + textDecoration: 'underline', + }, + ...sx, + }} + > + + + @{displayName} + + + + ); + } +} + +/** + * Parse text content containing inline mentions and render as React nodes. + * Supports: + * - `#[Tool Name]` or `#ToolName` or `#tool_name` + * - `@[path/to/file.ext]` or `@path/to/file.ext` or `@file.ext` + * + * @param {string} text + * @param {boolean} [isUser=false] + * @param {string} [keyPrefix='mention'] + * @returns {React.ReactNode[]} + */ +export function renderInlineMentions(text, isUser = false, keyPrefix = 'mention') { + if (typeof text !== 'string' || !text) return []; + + // Match token patterns: + // 1. §[Tool Name with spaces] (or #[...]) + // 2. @[File Name with spaces] + // 3. §tool_or_german_name (or #...) + // 4. @path/to/file.ext + const regex = /([§#]\[([^\]]+)\])|(@\[([^\]]+)\])|(?:^|(?<=[\s(]))([§#]([a-zA-Z0-9_\u00C0-\u017F\-]+))|(?:^|(?<=[\s(]))(@([a-zA-Z0-9_./\u00C0-\u017F\-]+(?:\.[a-zA-Z0-9]+)?))/g; + + const elements = []; + let lastIndex = 0; + let match; + let counter = 0; + + while ((match = regex.exec(text)) !== null) { + const matchStart = match.index; + const matchEnd = regex.lastIndex; + + // Push plain text preceding this match + if (matchStart > lastIndex) { + elements.push(text.slice(lastIndex, matchStart)); + } + + const [ + fullMatch, + bracketedToolFull, + bracketedToolName, + bracketedFileFull, + bracketedFileName, + bareToolFull, + bareToolName, + bareFileFull, + bareFileName, + ] = match; + + const k = `${keyPrefix}-${counter++}`; + + if (bracketedToolFull && bracketedToolName) { + const meta = getToolMeta(bracketedToolName); + if (meta) { + elements.push( + + ); + } else { + // Unknown bracketed tool name -> render styled pill anyway with fallback + elements.push( + + ); + } + } else if (bracketedFileFull && bracketedFileName) { + elements.push( + + ); + } else if (bareToolFull && bareToolName) { + const meta = getToolMeta(bareToolName); + if (meta) { + elements.push( + + ); + } else { + // Not a registered tool, keep as plain text (e.g. #123) + elements.push(fullMatch); + } + } else if (bareFileFull && bareFileName) { + // Check for trailing punctuation like . , ! ? : ; + const cleaned = bareFileName.replace(/[.,;:!?]+$/, ''); + const trailing = bareFileName.slice(cleaned.length); + + // Only treat as file if it has a file extension or slash, or is in VFS + const hasExtOrSlash = /\.[a-zA-Z0-9]+$/.test(cleaned) || cleaned.includes('/'); + const isKnownVfs = vfsAutomat.state.files?.some( + (f) => f.name === cleaned || f.name.endsWith(`/${cleaned}`) + ); + + if (hasExtOrSlash || isKnownVfs) { + elements.push( + + ); + if (trailing) { + elements.push(trailing); + } + } else { + elements.push(fullMatch); + } + } else { + elements.push(fullMatch); + } + + lastIndex = matchEnd; + } + + // Push remaining plain text + if (lastIndex < text.length) { + elements.push(text.slice(lastIndex)); + } + + return elements; +} + +/** + * Component that renders text with inline mentions. + */ +export class InlineMentionText extends PureComponent { + render() { + const { text, isUser = false } = this.props; + if (!text) return null; + return renderInlineMentions(text, isUser); + } +} diff --git a/src/components/chat/HealingMarkdown.jsx b/src/components/chat/HealingMarkdown.jsx index ada8f7e..0bb1b75 100644 --- a/src/components/chat/HealingMarkdown.jsx +++ b/src/components/chat/HealingMarkdown.jsx @@ -10,6 +10,8 @@ import { byMode, } from '../../colors'; import { VfsAttachmentLink, parseAttachmentTag, classifyLinkTarget, resolveVfsUrl } from './ChatAttachmentItems'; +import { ToolMentionPill, FileMentionPill } from './ChatMentionPill'; +import { getToolMeta } from './chatTools.jsx'; /** * Healing markdown renderer. @@ -598,6 +600,87 @@ function parseInline(src, keyPrefix = 'i') { } } + // Inline tool mention: §[Tool Name] or §tool_or_german_name (also supports #) + if ((c === '§' || c === '#') && (i === 0 || /[\s([<]/.test(src[i - 1]))) { + if (src[i + 1] === '[') { + const close = src.indexOf(']', i + 2); + if (close !== -1) { + const toolName = src.slice(i + 2, close); + const meta = getToolMeta(toolName); + const k = key(); + push( + + ); + i = close + 1; + continue; + } + } else { + const match = src.slice(i + 1).match(/^([a-zA-Z0-9_\u00C0-\u017F\-]+)/); + if (match) { + const raw = match[1]; + const meta = getToolMeta(raw); + if (meta) { + const k = key(); + push( + + ); + i += 1 + raw.length; + continue; + } + } + } + } + + // Inline VFS file mention: @[File Name] or @file.ext + if (c === '@' && (i === 0 || /[\s([<]/.test(src[i - 1]))) { + if (src[i + 1] === '[') { + const close = src.indexOf(']', i + 2); + if (close !== -1) { + const fileName = src.slice(i + 2, close); + const k = key(); + push( + + ); + i = close + 1; + continue; + } + } else { + const match = src.slice(i + 1).match(/^([a-zA-Z0-9_./\u00C0-\u017F\-]+)/); + if (match) { + const cleaned = match[1].replace(/[.,;:!?)]+$/, ''); + const hasExtOrSlash = /\.[a-zA-Z0-9]+$/.test(cleaned) || cleaned.includes('/'); + if (hasExtOrSlash) { + const k = key(); + push( + + ); + i += 1 + cleaned.length; + continue; + } + } + } + } + buf += c; i++; } diff --git a/src/components/chat/chatTools.js b/src/components/chat/chatTools.js new file mode 100644 index 0000000..428b6fe --- /dev/null +++ b/src/components/chat/chatTools.js @@ -0,0 +1 @@ +export * from './chatTools.jsx'; diff --git a/src/components/chat/chatTools.jsx b/src/components/chat/chatTools.jsx new file mode 100644 index 0000000..594a2f6 --- /dev/null +++ b/src/components/chat/chatTools.jsx @@ -0,0 +1,241 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import GoogleIcon from '@mui/icons-material/Google'; +import SearchRoundedIcon from '@mui/icons-material/SearchRounded'; +import ComputerRoundedIcon from '@mui/icons-material/ComputerRounded'; +import ImageSearchRoundedIcon from '@mui/icons-material/ImageSearchRounded'; +import CompareRoundedIcon from '@mui/icons-material/CompareRounded'; +import SupportIcon from '@mui/icons-material/Support'; +import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'; +import PhonelinkIcon from '@mui/icons-material/Phonelink'; +import SaveAsIcon from '@mui/icons-material/SaveAs'; +import DescriptionIcon from '@mui/icons-material/Description'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; +import DeleteIcon from '@mui/icons-material/Delete'; +import WebIcon from '@mui/icons-material/Web'; +import EngineeringIcon from '@mui/icons-material/Engineering'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import ManageSearchIcon from '@mui/icons-material/ManageSearch'; +import LensBlurIcon from '@mui/icons-material/LensBlur'; +import CalculateIcon from '@mui/icons-material/Calculate'; +import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; +import DirectionsIcon from '@mui/icons-material/Directions'; +import EditNoteIcon from '@mui/icons-material/EditNote'; + +export class GoogleFaviconIcon extends PureComponent { + state = { hasError: false }; + + handleError = () => { + this.setState({ hasError: true }); + }; + + render() { + const { sx } = this.props; + if (this.state.hasError) { + return ; + } + return ( + + ); + } +} + +export const TOOL_ICON_MAP = { + find_files: SearchRoundedIcon, + javascript_execute: ComputerRoundedIcon, + search_images: ImageSearchRoundedIcon, + analyze_image: CompareRoundedIcon, + apisuggestionsorissues: SupportIcon, + ask_user: RecordVoiceOverIcon, + dashback_request: PhonelinkIcon, + patch: SaveAsIcon, + read_file: DescriptionIcon, + reset_vfs: DeleteForeverIcon, + rm: DeleteIcon, + web_search: GoogleFaviconIcon, + url_get: WebIcon, + subagent: EngineeringIcon, + sub_agent: EngineeringIcon, + 'sub-agent': EngineeringIcon, + pdf_read: PictureAsPdfIcon, + search_articles: ManageSearchIcon, + edit_image: LensBlurIcon, + net_gross_calc: CalculateIcon, + ean_checksum: QrCodeScannerIcon, + 'set-user-route': DirectionsIcon, + set_user_route: DirectionsIcon, + draft_delivery_manifest: EditNoteIcon, + 'draft-delivery-manifest': EditNoteIcon, +}; + +export const TOOL_GERMAN_NAMES = { + find_files: 'Dateisuche', + javascript_execute: 'Codesandbox', + search_images: 'Bildersuche', + analyze_image: 'Bildanalyse', + apisuggestionsorissues: 'Hinweisbox', + ask_user: 'Rückfrage', + dashback_request: 'Datenbankabfrage', + patch: 'Datei bearbeiten', + read_file: 'Datei lesen', + reset_vfs: 'Dateispeicher löschen', + rm: 'Datei löschen', + web_search: 'Websuche', + url_get: 'Webdownload', + subagent: 'Subagent', + sub_agent: 'Subagent', + 'sub-agent': 'Subagent', + pdf_read: 'PDFzuText', + search_articles: 'Artikelsuche', + edit_image: 'Bildbearbeitung', + net_gross_calc: 'Netto-Brutto Rechner', + ean_checksum: 'Barcodeprüfer', + 'set-user-route': 'Benutzerführung', + set_user_route: 'Benutzerführung', + draft_delivery_manifest: 'Lieferungsentwurf ausfüllen', + 'draft-delivery-manifest': 'Lieferungsentwurf ausfüllen', +}; + +export const CANONICAL_TOOLS = [ + { name: 'find_files', label: 'Dateisuche', aliases: ['find', 'search_files'] }, + { name: 'read_file', label: 'Datei lesen', aliases: ['read', 'cat'] }, + { name: 'patch', label: 'Datei bearbeiten', aliases: ['edit', 'modify'] }, + { name: 'rm', label: 'Datei löschen', aliases: ['delete', 'remove'] }, + { name: 'reset_vfs', label: 'Dateispeicher löschen', aliases: ['clear_vfs'] }, + { name: 'javascript_execute', label: 'Codesandbox', aliases: ['js', 'execute', 'code'] }, + { name: 'search_articles', label: 'Artikelsuche', aliases: ['articles', 'artikel'] }, + { name: 'search_images', label: 'Bildersuche', aliases: ['images', 'bilder'] }, + { name: 'analyze_image', label: 'Bildanalyse', aliases: ['ocr', 'vision'] }, + { name: 'edit_image', label: 'Bildbearbeitung', aliases: ['crop', 'filter'] }, + { name: 'pdf_read', label: 'PDFzuText', aliases: ['pdf', 'pdftotext'] }, + { name: 'web_search', label: 'Websuche', aliases: ['google', 'search'] }, + { name: 'url_get', label: 'Webdownload', aliases: ['fetch', 'curl', 'download'] }, + { name: 'subagent', label: 'Subagent', aliases: ['sub_agent', 'sub-agent', 'agent'] }, + { name: 'dashback_request', label: 'Datenbankabfrage', aliases: ['db', 'sql', 'query'] }, + { name: 'apisuggestionsorissues', label: 'Hinweisbox', aliases: ['suggestions', 'issues', 'hinweise'] }, + { name: 'ask_user', label: 'Rückfrage', aliases: ['ask', 'frage'] }, + { name: 'net_gross_calc', label: 'Netto-Brutto Rechner', aliases: ['calc', 'mwst', 'tax', 'netto', 'brutto'] }, + { name: 'ean_checksum', label: 'Barcodeprüfer', aliases: ['ean', 'barcode', 'checksum'] }, + { name: 'set_user_route', label: 'Benutzerführung', aliases: ['set-user-route', 'route', 'navigation'] }, + { name: 'draft_delivery_manifest', label: 'Lieferungsentwurf ausfüllen', aliases: ['draft-delivery-manifest', 'lieferschein', 'delivery'] }, +]; + +/** + * Normalizes tool name or German label to lookup metadata. + * @param {string} keyOrLabel + * @returns {{ name: string, label: string, icon: React.ComponentType, rawName: string } | null} + */ +export function getToolMeta(keyOrLabel) { + if (!keyOrLabel || typeof keyOrLabel !== 'string') return null; + const trimmed = keyOrLabel.trim(); + const lower = trimmed.toLowerCase(); + + // Direct match on tool name in TOOL_GERMAN_NAMES + if (TOOL_GERMAN_NAMES[trimmed]) { + return { + name: trimmed, + label: TOOL_GERMAN_NAMES[trimmed], + icon: TOOL_ICON_MAP[trimmed] || SearchRoundedIcon, + rawName: trimmed, + }; + } + + // Normalized search across canonical tools + for (const t of CANONICAL_TOOLS) { + if ( + t.name.toLowerCase() === lower || + t.label.toLowerCase() === lower || + t.aliases.some((a) => a.toLowerCase() === lower) + ) { + return { + name: t.name, + label: t.label, + icon: TOOL_ICON_MAP[t.name] || SearchRoundedIcon, + rawName: t.name, + }; + } + } + + // Search across TOOL_GERMAN_NAMES values (e.g. if passed German label) + for (const [name, germanLabel] of Object.entries(TOOL_GERMAN_NAMES)) { + if (germanLabel.toLowerCase() === lower) { + return { + name, + label: germanLabel, + icon: TOOL_ICON_MAP[name] || SearchRoundedIcon, + rawName: name, + }; + } + } + + return null; +} + +/** + * Filter tools list by query substring. + * @param {string} query + * @returns {Array<{ name: string, label: string, icon: React.ComponentType, aliases: string[] }>} + */ +export function searchTools(query = '') { + const q = String(query || '').trim().toLowerCase(); + if (!q) { + return CANONICAL_TOOLS.map((t) => ({ + ...t, + icon: TOOL_ICON_MAP[t.name] || SearchRoundedIcon, + })); + } + + return CANONICAL_TOOLS.filter((t) => { + if (t.label.toLowerCase().includes(q)) return true; + if (t.name.toLowerCase().includes(q)) return true; + return t.aliases.some((a) => a.toLowerCase().includes(q)); + }).map((t) => ({ + ...t, + icon: TOOL_ICON_MAP[t.name] || SearchRoundedIcon, + })); +} + +/** + * Format mention token when selecting tool. + * Single word -> #Dateisuche + * With spaces -> #[Datei bearbeiten] + * @param {{ name: string, label: string }} tool + * @returns {string} + */ +export function getToolMentionToken(tool) { + if (!tool) return ''; + const label = tool.label || TOOL_GERMAN_NAMES[tool.name] || tool.name; + if (label.includes(' ')) { + return `§[${label}]`; + } + return `§${label}`; +} + +/** + * Format mention token when selecting file. + * Single word / path without spaces -> @artikel.csv + * With spaces -> @[Meine Datei.pdf] + * @param {string} fileName + * @returns {string} + */ +export function getFileMentionToken(fileName) { + if (!fileName) return ''; + if (fileName.includes(' ')) { + return `@[${fileName}]`; + } + return `@${fileName}`; +} diff --git a/src/components/chat/index.js b/src/components/chat/index.js index 91d239f..1354610 100644 --- a/src/components/chat/index.js +++ b/src/components/chat/index.js @@ -2,3 +2,6 @@ export { default as ChatFab } from './ChatFab'; export { default as ChatDrawer } from './ChatDrawer'; export { default as ChatFlowList } from './ChatFlowList'; export { default as NewConversationDialog } from './NewConversationDialog'; +export { default as ChatMentionAutocomplete } from './ChatMentionAutocomplete'; +export * from './chatTools.jsx'; +export * from './ChatMentionPill';