This commit is contained in:
seb
2026-10-18 23:32:53 +02:00
parent f7ee9182ec
commit 9b40d1db87
8 changed files with 1176 additions and 64 deletions

View File

@@ -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,

View File

@@ -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} />
)

View 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>
);
}
}

View 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);
}
}

View File

@@ -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++;
}

View File

@@ -0,0 +1 @@
export * from './chatTools.jsx';

View 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}`;
}

View File

@@ -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';