u
This commit is contained in:
@@ -47,6 +47,8 @@ import ChatFlowList from './ChatFlowList';
|
|||||||
import AskUserPrompt from './AskUserPrompt';
|
import AskUserPrompt from './AskUserPrompt';
|
||||||
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
|
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
|
||||||
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
|
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
|
||||||
|
import ChatMentionAutocomplete from './ChatMentionAutocomplete';
|
||||||
|
import { getToolMentionToken, getFileMentionToken, searchTools } from './chatTools.jsx';
|
||||||
import {
|
import {
|
||||||
BLUE_GLOW,
|
BLUE_GLOW,
|
||||||
DIALOG_FOG,
|
DIALOG_FOG,
|
||||||
@@ -172,6 +174,12 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
modelslugflag: 'floor',
|
modelslugflag: 'floor',
|
||||||
modelreasoning: 'medium',
|
modelreasoning: 'medium',
|
||||||
modelslug: 'openai/gpt-6-luna',
|
modelslug: 'openai/gpt-6-luna',
|
||||||
|
vfsFiles: vfs.files || [],
|
||||||
|
mentionOpen: false,
|
||||||
|
mentionType: null,
|
||||||
|
mentionQuery: '',
|
||||||
|
mentionStartPos: 0,
|
||||||
|
mentionSelectedIndex: 0,
|
||||||
};
|
};
|
||||||
this.inputRef = React.createRef();
|
this.inputRef = React.createRef();
|
||||||
this.fileInputRef = React.createRef();
|
this.fileInputRef = React.createRef();
|
||||||
@@ -295,6 +303,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
||||||
isVfsOpen: state.isOpen,
|
isVfsOpen: state.isOpen,
|
||||||
vfsWidth: state.vfsWidth,
|
vfsWidth: state.vfsWidth,
|
||||||
|
vfsFiles: state.files,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,6 +650,10 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
stagedReferences: [],
|
stagedReferences: [],
|
||||||
stagedExtractions: [],
|
stagedExtractions: [],
|
||||||
attachmentError: null,
|
attachmentError: null,
|
||||||
|
mentionOpen: false,
|
||||||
|
mentionType: null,
|
||||||
|
mentionQuery: '',
|
||||||
|
mentionSelectedIndex: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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) => {
|
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) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void this.handleSend();
|
void this.handleSend();
|
||||||
@@ -1507,7 +1653,18 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
</Box>
|
</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 */}
|
{/* Attachment Icon Button */}
|
||||||
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
|
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
|
||||||
<span>
|
<span>
|
||||||
@@ -1752,11 +1909,16 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
maxRows={5}
|
maxRows={5}
|
||||||
fullWidth
|
fullWidth
|
||||||
value={inputText}
|
value={inputText}
|
||||||
onChange={(e) => this.setState({ inputText: e.target.value })}
|
onChange={this.handleInputChange}
|
||||||
|
onSelect={this.handleInputSelect}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
onPaste={this.handlePaste}
|
onPaste={this.handlePaste}
|
||||||
onFocus={() => this.setState({ isInputFocused: true })}
|
onFocus={() => this.setState({ isInputFocused: true })}
|
||||||
onBlur={() => this.setState({ isInputFocused: false })}
|
onBlur={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ isInputFocused: false });
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
spellCheck: false,
|
spellCheck: false,
|
||||||
@@ -1769,7 +1931,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
? 'Bitte Frage oben beantworten…'
|
? 'Bitte Frage oben beantworten…'
|
||||||
: stagedAttachments.length > 0
|
: stagedAttachments.length > 0
|
||||||
? 'Nachricht hinzufügen oder direkt absenden…'
|
? 'Nachricht hinzufügen oder direkt absenden…'
|
||||||
: undefined
|
: '@ Datei · § Werkzeug erwähnen…'
|
||||||
}
|
}
|
||||||
sx={{
|
sx={{
|
||||||
px: 0.5,
|
px: 0.5,
|
||||||
|
|||||||
@@ -44,66 +44,14 @@ import {
|
|||||||
SHADOW_BUBBLE_USER,
|
SHADOW_BUBBLE_USER,
|
||||||
SURFACE_RAISED,
|
SURFACE_RAISED,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
|
import {
|
||||||
|
TOOL_ICON_MAP,
|
||||||
|
TOOL_GERMAN_NAMES,
|
||||||
|
GoogleFaviconIcon,
|
||||||
|
} from './chatTools.jsx';
|
||||||
|
import { InlineMentionText } from './ChatMentionPill';
|
||||||
|
|
||||||
class GoogleFaviconIcon extends PureComponent {
|
export { TOOL_ICON_MAP, TOOL_GERMAN_NAMES, GoogleFaviconIcon };
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatMessageTime(isoString) {
|
function formatMessageTime(isoString) {
|
||||||
if (!isoString) return '';
|
if (!isoString) return '';
|
||||||
@@ -1155,7 +1103,7 @@ function MessageRow({ msg, showTime = true }) {
|
|||||||
|
|
||||||
{cleanText ? (
|
{cleanText ? (
|
||||||
isUser ? (
|
isUser ? (
|
||||||
cleanText
|
<InlineMentionText text={cleanText} isUser={true} />
|
||||||
) : (
|
) : (
|
||||||
<HealingMarkdown text={cleanText} streaming={msg.streaming} />
|
<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,
|
byMode,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
import { VfsAttachmentLink, parseAttachmentTag, classifyLinkTarget, resolveVfsUrl } from './ChatAttachmentItems';
|
import { VfsAttachmentLink, parseAttachmentTag, classifyLinkTarget, resolveVfsUrl } from './ChatAttachmentItems';
|
||||||
|
import { ToolMentionPill, FileMentionPill } from './ChatMentionPill';
|
||||||
|
import { getToolMeta } from './chatTools.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Healing markdown renderer.
|
* 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;
|
buf += c;
|
||||||
i++;
|
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 ChatDrawer } from './ChatDrawer';
|
||||||
export { default as ChatFlowList } from './ChatFlowList';
|
export { default as ChatFlowList } from './ChatFlowList';
|
||||||
export { default as NewConversationDialog } from './NewConversationDialog';
|
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