u
This commit is contained in:
@@ -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 {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1, position: 'relative' }}>
|
||||
<ChatMentionAutocomplete
|
||||
open={this.state.mentionOpen}
|
||||
type={this.state.mentionType}
|
||||
query={this.state.mentionQuery}
|
||||
files={this.state.vfsFiles}
|
||||
selectedIndex={this.state.mentionSelectedIndex}
|
||||
onSelect={this.handleMentionSelect}
|
||||
onHoverIndex={(idx) => this.setState({ mentionSelectedIndex: idx })}
|
||||
onClose={() => this.setState({ mentionOpen: false, mentionType: null })}
|
||||
/>
|
||||
|
||||
{/* Attachment Icon Button */}
|
||||
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
|
||||
<span>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <GoogleIcon sx={sx} />;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src="https://www.google.com/favicon.ico"
|
||||
alt="Google"
|
||||
onError={this.handleError}
|
||||
sx={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '2px',
|
||||
objectFit: 'contain',
|
||||
verticalAlign: 'middle',
|
||||
display: 'inline-block',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
<InlineMentionText text={cleanText} isUser={true} />
|
||||
) : (
|
||||
<HealingMarkdown text={cleanText} streaming={msg.streaming} />
|
||||
)
|
||||
|
||||
341
src/components/chat/ChatMentionAutocomplete.jsx
Normal file
341
src/components/chat/ChatMentionAutocomplete.jsx
Normal file
@@ -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 (
|
||||
<Paper
|
||||
elevation={10}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: 10,
|
||||
right: 10,
|
||||
maxHeight: 320,
|
||||
zIndex: 1300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 2.5,
|
||||
overflow: 'hidden',
|
||||
bgcolor: (theme) =>
|
||||
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 */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.75,
|
||||
py: 0.9,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.04)'
|
||||
: 'rgba(0, 0, 0, 0.03)',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{type === 'tool' ? (
|
||||
<AutoFixHighRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'primary.main' }}
|
||||
/>
|
||||
) : (
|
||||
<DescriptionRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'primary.main' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.76rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.1px',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{type === 'tool' ? 'Werkzeuge & Aktionen' : 'VFS-Dateien'}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
color: 'text.disabled',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
({items.length})
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.8 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.68rem',
|
||||
color: 'text.secondary',
|
||||
bgcolor: (theme) =>
|
||||
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'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable list of items */}
|
||||
<Box
|
||||
ref={this.listRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
maxHeight: 230,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<Box sx={{ py: 3, px: 2, textAlign: 'center' }}>
|
||||
<Typography
|
||||
sx={{ fontSize: '0.8rem', color: 'text.secondary', fontStyle: 'italic' }}
|
||||
>
|
||||
Keine passenden {type === 'tool' ? 'Werkzeuge' : 'Dateien'} gefunden
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const ToolIcon = type === 'tool' ? item.icon : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={type === 'tool' ? item.name : item.name}
|
||||
ref={(el) => (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 */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 1.25,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.06)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
}}
|
||||
>
|
||||
{type === 'tool' ? (
|
||||
ToolIcon ? (
|
||||
<ToolIcon sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||
) : (
|
||||
<AutoFixHighRoundedIcon sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||
)
|
||||
) : (
|
||||
<VfsFileIcon fileName={item.name} sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Main Label and Details */}
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: isSelected ? 700 : 600,
|
||||
color: isSelected ? 'primary.main' : 'text.primary',
|
||||
fontFamily: type === 'file' ? 'monospace' : 'inherit',
|
||||
}}
|
||||
>
|
||||
{type === 'tool' ? item.label : item.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{type === 'tool' && (
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
color: 'text.secondary',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
§{item.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right pill / size */}
|
||||
{type === 'file' && item.size != null && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
color: 'text.disabled',
|
||||
fontFamily: 'monospace',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formatVfsSize(item.size)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === 'tool' && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '0.68rem',
|
||||
color: 'text.secondary',
|
||||
bgcolor: (theme) =>
|
||||
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
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer shortcuts */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
bgcolor: (theme) =>
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Box component="span">
|
||||
<Box component="strong" sx={{ color: 'text.primary' }}>↑↓</Box> Navigieren
|
||||
</Box>
|
||||
<Box component="span">
|
||||
<Box component="strong" sx={{ color: 'text.primary' }}>↵ / Tab</Box> Auswählen
|
||||
</Box>
|
||||
<Box component="span">
|
||||
<Box component="strong" sx={{ color: 'text.primary' }}>Esc</Box> Abbrechen
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
333
src/components/chat/ChatMentionPill.jsx
Normal file
333
src/components/chat/ChatMentionPill.jsx
Normal file
@@ -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 (
|
||||
<Tooltip
|
||||
title={`Werkzeug: ${meta?.name || name} (${displayLabel})`}
|
||||
placement="top"
|
||||
arrow
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.45,
|
||||
px: 0.75,
|
||||
py: 0.1,
|
||||
mx: 0.25,
|
||||
my: 0.1,
|
||||
verticalAlign: 'baseline',
|
||||
borderRadius: 1.25,
|
||||
fontSize: '0.78rem',
|
||||
lineHeight: 1.4,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.1px',
|
||||
userSelect: 'none',
|
||||
bgcolor: isUser
|
||||
? 'rgba(255, 255, 255, 0.22)'
|
||||
: (theme) =>
|
||||
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 && (
|
||||
<ToolIcon
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: isUser ? 'inherit' : 'primary.main',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
§{displayLabel}
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Tooltip
|
||||
title={`VFS-Datei öffnen: ${target}`}
|
||||
placement="top"
|
||||
arrow
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
onClick={this.handleClick}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.45,
|
||||
px: 0.75,
|
||||
py: 0.1,
|
||||
mx: 0.25,
|
||||
my: 0.1,
|
||||
verticalAlign: 'baseline',
|
||||
borderRadius: 1.25,
|
||||
fontSize: '0.78rem',
|
||||
lineHeight: 1.4,
|
||||
fontWeight: 600,
|
||||
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
|
||||
letterSpacing: '-0.2px',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
bgcolor: isUser
|
||||
? 'rgba(255, 255, 255, 0.22)'
|
||||
: (theme) =>
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<VfsFileIcon
|
||||
fileName={displayName}
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: isUser ? 'inherit' : undefined,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
||||
@{displayName}
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
<ToolMentionPill
|
||||
key={k}
|
||||
name={meta.name}
|
||||
label={meta.label}
|
||||
isUser={isUser}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Unknown bracketed tool name -> render styled pill anyway with fallback
|
||||
elements.push(
|
||||
<ToolMentionPill
|
||||
key={k}
|
||||
name={bracketedToolName}
|
||||
label={bracketedToolName}
|
||||
isUser={isUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (bracketedFileFull && bracketedFileName) {
|
||||
elements.push(
|
||||
<FileMentionPill
|
||||
key={k}
|
||||
fileName={bracketedFileName}
|
||||
path={bracketedFileName}
|
||||
isUser={isUser}
|
||||
/>
|
||||
);
|
||||
} else if (bareToolFull && bareToolName) {
|
||||
const meta = getToolMeta(bareToolName);
|
||||
if (meta) {
|
||||
elements.push(
|
||||
<ToolMentionPill
|
||||
key={k}
|
||||
name={meta.name}
|
||||
label={meta.label}
|
||||
isUser={isUser}
|
||||
/>
|
||||
);
|
||||
} 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(
|
||||
<FileMentionPill
|
||||
key={k}
|
||||
fileName={cleaned}
|
||||
path={cleaned}
|
||||
isUser={isUser}
|
||||
/>
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
<ToolMentionPill
|
||||
key={k}
|
||||
name={meta?.name || toolName}
|
||||
label={meta?.label || toolName}
|
||||
isUser={false}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<ToolMentionPill
|
||||
key={k}
|
||||
name={meta.name}
|
||||
label={meta.label}
|
||||
isUser={false}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<FileMentionPill
|
||||
key={k}
|
||||
fileName={fileName}
|
||||
path={fileName}
|
||||
isUser={false}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<FileMentionPill
|
||||
key={k}
|
||||
fileName={cleaned}
|
||||
path={cleaned}
|
||||
isUser={false}
|
||||
/>
|
||||
);
|
||||
i += 1 + cleaned.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf += c;
|
||||
i++;
|
||||
}
|
||||
|
||||
1
src/components/chat/chatTools.js
Normal file
1
src/components/chat/chatTools.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './chatTools.jsx';
|
||||
241
src/components/chat/chatTools.jsx
Normal file
241
src/components/chat/chatTools.jsx
Normal file
@@ -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 <GoogleIcon sx={sx} />;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src="https://www.google.com/favicon.ico"
|
||||
alt="Google"
|
||||
onError={this.handleError}
|
||||
sx={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '2px',
|
||||
objectFit: 'contain',
|
||||
verticalAlign: 'middle',
|
||||
display: 'inline-block',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user