This commit is contained in:
seb
2026-10-16 15:38:39 +02:00
parent 5c57097ad0
commit 52411ac45d
5 changed files with 298 additions and 48 deletions

View File

@@ -8,8 +8,14 @@ import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import CircularProgress from '@mui/material/CircularProgress';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
@@ -202,6 +208,218 @@ export function parseAttachmentTag(attrString) {
export { stripUserEnv };
/**
* Extract all `[EXTRACTION ...]` blocks (or legacy model instruction code blocks)
* and return structured extraction objects along with clean non-invoice text.
* @param {string} rawText
* @returns {{ extractions: Array<{ label: string, text: string }>, cleanText: string }}
*/
export function parseExtractionsFromText(rawText) {
if (typeof rawText !== 'string' || !rawText) {
return { extractions: [], cleanText: '' };
}
const extractions = [];
let cleanText = rawText;
// 1. Explicit [EXTRACTION label="..."]...[/EXTRACTION] or <extraction label="...">...</extraction>
const tagRegex = /(?:\[EXTRACTION(?:\s+label="([^"]*)")?\]|<extraction(?:\s+label="([^"]*)")?>)([\s\S]*?)(?:\[\/EXTRACTION\]|<\/extraction>)/gi;
let match;
while ((match = tagRegex.exec(rawText)) !== null) {
const label = match[1] || match[2] || 'parsed invoice';
let text = match[3] || '';
text = text.replace(/^```[a-z]*\s*\n?|\s*```$/gi, '').trim();
extractions.push({ label, text });
}
if (extractions.length > 0) {
cleanText = cleanText.replace(tagRegex, '').trim();
return { extractions, cleanText };
}
// 2. Legacy / fallback: message contains "use 'Process Incoming Product and Invoices' SKILL" + code block
const instructionPhrase = "use 'Process Incoming Product and Invoices' SKILL to fill drafts/delivery vfs file";
if (cleanText.includes(instructionPhrase)) {
const codeBlockRegex = /```[a-z]*\s*\n([\s\S]*?)```/g;
let cbMatch;
while ((cbMatch = codeBlockRegex.exec(cleanText)) !== null) {
const code = (cbMatch[1] || '').trim();
if (code.length > 20) {
extractions.push({ label: 'parsed invoice', text: code });
}
}
if (extractions.length > 0) {
cleanText = cleanText.replace(codeBlockRegex, '').trim();
return { extractions, cleanText };
}
}
// 3. Standalone code block (e.g. ```\n...invoice...```)
const soleCodeBlockMatch = cleanText.match(/^\s*```[a-z]*\s*\n([\s\S]{40,})```\s*$/);
if (soleCodeBlockMatch) {
const code = soleCodeBlockMatch[1].trim();
extractions.push({ label: 'parsed invoice', text: code });
cleanText = '';
return { extractions, cleanText };
}
return { extractions: [], cleanText };
}
/**
* Clickable pill representing an extracted invoice/data snippet inside a message bubble.
* Clicking opens a modal preview to inspect and copy the full text.
*/
export class ExtractionPill extends PureComponent {
state = {
openPreview: false,
};
handleClick = (e) => {
e?.preventDefault?.();
e?.stopPropagation?.();
this.setState({ openPreview: true });
};
handleClose = () => {
this.setState({ openPreview: false });
};
handleCopy = () => {
const { text } = this.props;
if (text && navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text);
enqueueSnackbar('Extrahierter Text kopiert', { variant: 'success' });
}
};
render() {
const { label = 'parsed invoice', text = '', isUser = false } = this.props;
const { openPreview } = this.state;
return (
<>
<Box
component="button"
type="button"
onClick={this.handleClick}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
my: 0.35,
mx: 0.2,
pl: 1.1,
pr: 1,
py: 0.45,
borderRadius: 2,
border: '1px solid',
borderColor: isUser ? 'rgba(255, 255, 255, 0.4)' : 'divider',
bgcolor: isUser
? 'rgba(255, 255, 255, 0.2)'
: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)'),
color: isUser ? '#fff' : 'text.primary',
cursor: 'pointer',
verticalAlign: 'middle',
maxWidth: '100%',
outline: 'none',
fontFamily: 'inherit',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isUser ? 'rgba(255, 255, 255, 0.3)' : 'action.hover',
borderColor: isUser ? '#fff' : 'primary.main',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
},
}}
>
<DescriptionRoundedIcon sx={{ fontSize: 16, color: isUser ? '#fff' : 'primary.main', flexShrink: 0 }} />
<Typography
component="span"
sx={{
fontSize: '0.8rem',
fontWeight: 600,
color: 'inherit',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1.25,
}}
>
{label || 'parsed invoice'}
</Typography>
</Box>
<Dialog
open={openPreview}
onClose={this.handleClose}
maxWidth="md"
fullWidth
sx={(theme) => ({
zIndex: theme.zIndex.modal + 50,
})}
slotProps={{
paper: {
sx: {
borderRadius: 2.5,
bgcolor: 'background.paper',
backgroundImage: 'none',
},
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionRoundedIcon sx={{ color: 'primary.main', fontSize: 22 }} />
<Typography variant="h6" component="span" sx={{ fontSize: '1.05rem', fontWeight: 600 }}>
{label || 'parsed invoice'}
</Typography>
</Box>
<IconButton size="small" onClick={this.handleClose} aria-label="Schließen">
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}>
<Box
component="pre"
sx={{
m: 0,
p: 1.5,
borderRadius: 1.5,
fontFamily: 'monospace',
fontSize: '0.8rem',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflow: 'auto',
maxHeight: '60vh',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
{text}
</Box>
</DialogContent>
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
<Button
startIcon={<ContentCopyRoundedIcon sx={{ fontSize: 18 }} />}
onClick={this.handleCopy}
color="primary"
variant="outlined"
size="small"
>
Kopieren
</Button>
<Button onClick={this.handleClose} color="inherit" size="small">
Schließen
</Button>
</DialogActions>
</Dialog>
</>
);
}
}
/**
* Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata.
* Also strips <USERENV> tags so route context is never rendered in chat views.