Compare commits
3 Commits
ee49ed29de
...
cbf669f640
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf669f640 | ||
|
|
4769a87c54 | ||
|
|
03608017f3 |
@@ -97,14 +97,19 @@ function scheduleRebuild() {
|
|||||||
*/
|
*/
|
||||||
function toIndexRecord(article, categoryId) {
|
function toIndexRecord(article, categoryId) {
|
||||||
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
||||||
|
const childCount = Number(article.childCount) || 0;
|
||||||
|
const price = article.price ?? article.net ?? article.vknetto ?? null;
|
||||||
return {
|
return {
|
||||||
...article,
|
...article,
|
||||||
id: article.id,
|
id: article.id,
|
||||||
sku: article.sku,
|
sku: article.sku,
|
||||||
name: article.name,
|
name: article.name,
|
||||||
|
price,
|
||||||
|
net: article.net ?? price,
|
||||||
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
|
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
|
||||||
categories: categoryId != null ? String(categoryId) : null,
|
categories: categoryId != null ? String(categoryId) : null,
|
||||||
isParent: Boolean(article.isParent ?? article.is_parent),
|
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
|
||||||
|
childCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +140,20 @@ export function buildArticleIndex(views) {
|
|||||||
ids.add(String(categoryId));
|
ids.add(String(categoryId));
|
||||||
existing.categories = Array.from(ids).join(',');
|
existing.categories = Array.from(ids).join(',');
|
||||||
}
|
}
|
||||||
existing.isParent = Boolean(existing.isParent || (article.isParent ?? article.is_parent));
|
const childCount = Number(article.childCount) || 0;
|
||||||
|
if (childCount > (Number(existing.childCount) || 0)) {
|
||||||
|
existing.childCount = childCount;
|
||||||
|
}
|
||||||
|
existing.isParent = Boolean(
|
||||||
|
existing.isParent ||
|
||||||
|
(article.isParent ?? article.is_parent) ||
|
||||||
|
(childCount > 0)
|
||||||
|
);
|
||||||
|
if (existing.price == null && (article.price != null || article.net != null)) {
|
||||||
|
const p = article.price ?? article.net ?? article.vknetto;
|
||||||
|
existing.price = p;
|
||||||
|
existing.net = article.net ?? p;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -962,11 +962,22 @@ const actions = {
|
|||||||
/**
|
/**
|
||||||
* Send a user message via POST /api/modelconversation/:id/message.
|
* Send a user message via POST /api/modelconversation/:id/message.
|
||||||
* Rejects immediately if conversation is busy (409 Conflict avoidance).
|
* Rejects immediately if conversation is busy (409 Conflict avoidance).
|
||||||
* @param {string} text
|
* @param {string|{ text?: string, attachments?: object[] }} textOrPayload
|
||||||
|
* @param {object[]} [maybeAttachments]
|
||||||
*/
|
*/
|
||||||
async sendMessage(text) {
|
async sendMessage(textOrPayload, maybeAttachments = []) {
|
||||||
const trimmed = text?.trim();
|
let trimmed = '';
|
||||||
if (!trimmed) return;
|
let fileAttachments = [];
|
||||||
|
|
||||||
|
if (typeof textOrPayload === 'object' && textOrPayload !== null) {
|
||||||
|
trimmed = (textOrPayload.text || '').trim();
|
||||||
|
fileAttachments = Array.isArray(textOrPayload.attachments) ? textOrPayload.attachments : [];
|
||||||
|
} else {
|
||||||
|
trimmed = (textOrPayload || '').trim();
|
||||||
|
fileAttachments = Array.isArray(maybeAttachments) ? maybeAttachments : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed && fileAttachments.length === 0) return;
|
||||||
|
|
||||||
if (!chatAutomat.state.selectedConversationId && chatAutomat.state.draftConversation) {
|
if (!chatAutomat.state.selectedConversationId && chatAutomat.state.draftConversation) {
|
||||||
// Draft: the conversation really starts now, with the first message.
|
// Draft: the conversation really starts now, with the first message.
|
||||||
@@ -983,11 +994,23 @@ const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
|
let finalContent = trimmed;
|
||||||
|
if (fileAttachments.length > 0) {
|
||||||
|
const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val);
|
||||||
|
const tags = fileAttachments.map((f) => {
|
||||||
|
const vfsRelPath = f.path || `attachments/${f.name}`;
|
||||||
|
const mime = f.mimeType || f.type || 'application/octet-stream';
|
||||||
|
return `[ATTACHMENT name=${formatAttr(f.name)} path=${formatAttr(vfsRelPath)} type=${formatAttr(mime)}]`;
|
||||||
|
});
|
||||||
|
const prefix = tags.join(' ');
|
||||||
|
finalContent = trimmed ? `${prefix}\n\n${trimmed}` : prefix;
|
||||||
|
}
|
||||||
|
|
||||||
const optimisticMsg = {
|
const optimisticMsg = {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
conversation_id: selectedConversationId,
|
conversation_id: selectedConversationId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
message: trimmed,
|
message: JSON.stringify({ content: finalContent }),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1014,7 +1037,10 @@ const actions = {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await modelconversationApi.addMessage(selectedConversationId, { message: trimmed });
|
const res = await modelconversationApi.addMessage(selectedConversationId, {
|
||||||
|
message: trimmed,
|
||||||
|
attachments: fileAttachments.length > 0 ? fileAttachments : undefined,
|
||||||
|
});
|
||||||
const createdMsg = res?.message || optimisticMsg;
|
const createdMsg = res?.message || optimisticMsg;
|
||||||
chatAutomat.setState((prev) => {
|
chatAutomat.setState((prev) => {
|
||||||
const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId);
|
const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { BrandLabel } from '../brand';
|
|||||||
import { formatPrice } from '../../utils/price';
|
import { formatPrice } from '../../utils/price';
|
||||||
import { formatStock } from './searchFormat';
|
import { formatStock } from './searchFormat';
|
||||||
import ResultsPagination from './ResultsPagination';
|
import ResultsPagination from './ResultsPagination';
|
||||||
import { BORDER } from '../../colors';
|
import { BORDER, SCRIM_LABEL } from '../../colors';
|
||||||
|
|
||||||
const searchResultRowSx = {
|
const searchResultRowSx = {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -45,6 +45,8 @@ const searchResultRowSx = {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
height: '22px',
|
height: '22px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
'& .MuiListItemText-secondary': {
|
'& .MuiListItemText-secondary': {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -512,7 +514,52 @@ export default class ArticleSearch extends PureComponent {
|
|||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
|
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={article.name || '–'}
|
primary={
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.name || '–'}
|
||||||
|
</Box>
|
||||||
|
{Boolean(article.isParent || article.is_parent || (Number(article.childCount) > 0)) && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
title={`${Number(article.childCount) || 0} Varianten`}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: SCRIM_LABEL,
|
||||||
|
color: 'common.white',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Number(article.childCount) || 0}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
secondary={
|
secondary={
|
||||||
<Typography
|
<Typography
|
||||||
component="span"
|
component="span"
|
||||||
@@ -772,6 +819,30 @@ export default class ArticleSearch extends PureComponent {
|
|||||||
<Typography variant="body2" fontWeight={600} noWrap sx={{ flex: 1 }}>
|
<Typography variant="body2" fontWeight={600} noWrap sx={{ flex: 1 }}>
|
||||||
{article.name || '–'}
|
{article.name || '–'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{Boolean(article.isParent || article.is_parent || (Number(article.childCount) > 0)) && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
title={`${Number(article.childCount) || 0} Varianten`}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.2,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: SCRIM_LABEL,
|
||||||
|
color: 'common.white',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Number(article.childCount) || 0}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25, height: 18, minHeight: 18 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25, height: 18, minHeight: 18 }}>
|
||||||
{(article.brand?.name || article.brandName) && (
|
{(article.brand?.name || article.brandName) && (
|
||||||
|
|||||||
497
src/components/chat/ChatAttachmentItems.jsx
Normal file
497
src/components/chat/ChatAttachmentItems.jsx
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatAttachmentItems.jsx
|
||||||
|
* @description UI components for rendering staged attachments in the input tray,
|
||||||
|
* and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` tags in messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
|
||||||
|
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
|
||||||
|
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
||||||
|
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
|
||||||
|
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
|
||||||
|
import InsertPhotoRoundedIcon from '@mui/icons-material/InsertPhotoRounded';
|
||||||
|
import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SUPPORTED_CATEGORIES,
|
||||||
|
formatFileSize,
|
||||||
|
} from './chatAttachments';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a relative VFS path (e.g. `attachments/Rechnung 89589.txt`) to a full browser URL (`/api/vfs/...`).
|
||||||
|
* @param {string} rawPath
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function resolveVfsUrl(rawPath) {
|
||||||
|
const p = String(rawPath ?? '').trim();
|
||||||
|
if (!p) return '#';
|
||||||
|
if (/^https?:\/\//i.test(p) || p.startsWith('/')) return p;
|
||||||
|
const segments = p
|
||||||
|
.replace(/^\.\//, '')
|
||||||
|
.split('/')
|
||||||
|
.filter((s) => s && s !== '.')
|
||||||
|
.map(encodeURIComponent);
|
||||||
|
return `/api/vfs/${segments.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse attributes from an attachment tag:
|
||||||
|
* `name="Rechnung 89589.txt" path="attachments/Rechnung 89589.txt" type=text/plain`
|
||||||
|
* @param {string} attrString
|
||||||
|
* @returns {{ name: string, path: string, type: string }}
|
||||||
|
*/
|
||||||
|
export function parseAttachmentTag(attrString) {
|
||||||
|
const attrs = {};
|
||||||
|
const regex = /([a-zA-Z0-9_-]+)=(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/g;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(attrString)) !== null) {
|
||||||
|
const key = m[1].toLowerCase();
|
||||||
|
const val = m[2] ?? m[3] ?? m[4] ?? '';
|
||||||
|
attrs[key] = val;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: attrs.name || (attrs.path ? attrs.path.split('/').pop() : 'Datei'),
|
||||||
|
path: attrs.path || '',
|
||||||
|
type: attrs.type || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata.
|
||||||
|
* @param {string} rawText
|
||||||
|
* @returns {{ attachments: Array<{ name: string, path: string, type: string, url: string }>, cleanText: string }}
|
||||||
|
*/
|
||||||
|
export function parseAttachmentsFromText(rawText) {
|
||||||
|
if (typeof rawText !== 'string' || !rawText.includes('[ATTACHMENT')) {
|
||||||
|
return { attachments: [], cleanText: rawText || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = [];
|
||||||
|
const tagRegex = /\[ATTACHMENT\s+([^\]]+)\]/g;
|
||||||
|
let match;
|
||||||
|
while ((match = tagRegex.exec(rawText)) !== null) {
|
||||||
|
const parsed = parseAttachmentTag(match[1]);
|
||||||
|
if (parsed.name || parsed.path) {
|
||||||
|
attachments.push({
|
||||||
|
...parsed,
|
||||||
|
url: resolveVfsUrl(parsed.path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanText = rawText.replace(tagRegex, '').trim();
|
||||||
|
return { attachments, cleanText };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachmentIcon(type, name, isUser) {
|
||||||
|
const t = (type || '').toLowerCase();
|
||||||
|
const n = (name || '').toLowerCase();
|
||||||
|
const sx = { fontSize: 20, color: isUser ? 'inherit' : undefined, flexShrink: 0 };
|
||||||
|
|
||||||
|
if (t === 'application/pdf' || n.endsWith('.pdf')) {
|
||||||
|
return <PictureAsPdfRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#e53935' }} />;
|
||||||
|
}
|
||||||
|
if (t.includes('csv') || n.endsWith('.csv')) {
|
||||||
|
return <TableChartRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#43a047' }} />;
|
||||||
|
}
|
||||||
|
if (t.includes('xml') || t.includes('html') || n.endsWith('.xml') || n.endsWith('.html') || n.endsWith('.htm')) {
|
||||||
|
return <CodeRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#fb8c00' }} />;
|
||||||
|
}
|
||||||
|
if (t.startsWith('text/') || n.endsWith('.txt') || n.endsWith('.md') || n.endsWith('.log')) {
|
||||||
|
return <DescriptionRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#0288d1' }} />;
|
||||||
|
}
|
||||||
|
if (t.startsWith('image/') || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(n)) {
|
||||||
|
return <InsertPhotoRoundedIcon sx={{ ...sx, color: isUser ? 'inherit' : '#8e24aa' }} />;
|
||||||
|
}
|
||||||
|
return <InsertDriveFileRoundedIcon sx={sx} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct link to VFS rendered for `[ATTACHMENT name="..." path="..." type=...]`.
|
||||||
|
* Safely verifies HTTP response before triggering download, preventing saving 404 JSON error responses.
|
||||||
|
*/
|
||||||
|
export class VfsAttachmentLink extends PureComponent {
|
||||||
|
state = {
|
||||||
|
isDownloading: false,
|
||||||
|
notFound: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClick = async (e) => {
|
||||||
|
// If modifier keys are pressed (e.g. open in new tab with Ctrl/Cmd), let browser handle
|
||||||
|
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const { name, path } = this.props;
|
||||||
|
const { isDownloading, notFound } = this.state;
|
||||||
|
if (isDownloading) return;
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
enqueueSnackbar(`Datei "${name}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vfsUrl = resolveVfsUrl(path);
|
||||||
|
this.setState({ isDownloading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(vfsUrl, { credentials: 'include' });
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
this.setState({ notFound: true, isDownloading: false });
|
||||||
|
enqueueSnackbar(`Datei "${name}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
||||||
|
} else {
|
||||||
|
this.setState({ isDownloading: false });
|
||||||
|
enqueueSnackbar(`Fehler beim Herunterladen von "${name}" (${res.status}).`, { variant: 'error' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const tempLink = document.createElement('a');
|
||||||
|
tempLink.href = objectUrl;
|
||||||
|
tempLink.download = name || 'datei';
|
||||||
|
document.body.appendChild(tempLink);
|
||||||
|
tempLink.click();
|
||||||
|
document.body.removeChild(tempLink);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
this.setState({ isDownloading: false });
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ isDownloading: false });
|
||||||
|
enqueueSnackbar(`Netzwerkfehler beim Herunterladen: ${err?.message || err}`, { variant: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleImageError = () => {
|
||||||
|
this.setState({ notFound: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { name, path, type, isUser = false } = this.props;
|
||||||
|
const { isDownloading, notFound } = this.state;
|
||||||
|
const vfsUrl = resolveVfsUrl(path);
|
||||||
|
const isImage = !notFound && ((type && type.startsWith('image/')) || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(name || path));
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={vfsUrl}
|
||||||
|
onClick={this.handleClick}
|
||||||
|
title={isDownloading ? 'Wird heruntergeladen…' : `VFS Bild öffnen / herunterladen: ${name}`}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
my: 0.6,
|
||||||
|
maxWidth: 280,
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: isDownloading ? 0.75 : 1,
|
||||||
|
transition: 'transform 0.15s, box-shadow 0.15s, opacity 0.15s',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={vfsUrl}
|
||||||
|
alt={name}
|
||||||
|
onError={this.handleImageError}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: 200,
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 0.75,
|
||||||
|
px: 1,
|
||||||
|
py: 0.5,
|
||||||
|
bgcolor: isUser ? 'rgba(0,0,0,0.3)' : (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'),
|
||||||
|
color: isUser ? '#fff' : 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||||
|
<InsertPhotoRoundedIcon sx={{ fontSize: 16 }} />
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{isDownloading && <CircularProgress size={12} color="inherit" thickness={5} />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleText = notFound
|
||||||
|
? `Datei nicht mehr im VFS vorhanden (404): ${name}`
|
||||||
|
: isDownloading
|
||||||
|
? `Wird heruntergeladen…`
|
||||||
|
: `VFS Datei herunterladen / öffnen: ${name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={titleText} arrow>
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={notFound ? undefined : vfsUrl}
|
||||||
|
onClick={this.handleClick}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
my: 0.35,
|
||||||
|
mx: 0.2,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.65,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: notFound
|
||||||
|
? (isUser ? 'rgba(255,255,255,0.2)' : 'error.light')
|
||||||
|
: (isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider'),
|
||||||
|
bgcolor: notFound
|
||||||
|
? (isUser ? 'rgba(0,0,0,0.2)' : 'action.disabledBackground')
|
||||||
|
: (isUser
|
||||||
|
? 'rgba(255, 255, 255, 0.18)'
|
||||||
|
: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)')),
|
||||||
|
textDecoration: notFound ? 'line-through' : 'none',
|
||||||
|
color: notFound
|
||||||
|
? (isUser ? 'rgba(255,255,255,0.6)' : 'text.disabled')
|
||||||
|
: (isUser ? 'inherit' : 'primary.main'),
|
||||||
|
opacity: notFound ? 0.7 : (isDownloading ? 0.75 : 1),
|
||||||
|
cursor: notFound ? 'not-allowed' : 'pointer',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
maxWidth: '100%',
|
||||||
|
transition: 'all 150ms ease-in-out',
|
||||||
|
'&:hover': notFound ? {} : {
|
||||||
|
bgcolor: isUser ? 'rgba(255, 255, 255, 0.28)' : 'action.hover',
|
||||||
|
borderColor: isUser ? '#fff' : 'primary.main',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<CircularProgress size={16} color="inherit" thickness={5} />
|
||||||
|
) : notFound ? (
|
||||||
|
<ErrorOutlineRoundedIcon sx={{ fontSize: 20, color: isUser ? 'inherit' : 'error.main' }} />
|
||||||
|
) : (
|
||||||
|
getAttachmentIcon(type, name, isUser)
|
||||||
|
)}
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'inherit',
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: 260,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
textDecoration: notFound ? 'line-through' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
opacity: 0.8,
|
||||||
|
display: 'block',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notFound ? 'nicht mehr vorhanden (404)' : (type || 'VFS Datei')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tray showing files staged for posting above the chat input field.
|
||||||
|
*/
|
||||||
|
export class ChatStagedAttachmentsTray extends PureComponent {
|
||||||
|
render() {
|
||||||
|
const { attachments, onRemove } = this.props;
|
||||||
|
if (!attachments || attachments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1,
|
||||||
|
p: 1,
|
||||||
|
mb: 0.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
|
||||||
|
border: '1px dashed',
|
||||||
|
borderColor: 'divider',
|
||||||
|
maxHeight: 140,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{attachments.map((att, idx) => {
|
||||||
|
const isImage = att.category === SUPPORTED_CATEGORIES.IMAGE;
|
||||||
|
const key = att.id || `staged-${idx}`;
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 54,
|
||||||
|
height: 54,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
flexShrink: 0,
|
||||||
|
'&:hover .remove-btn': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={att.dataUrl || att.url}
|
||||||
|
alt={att.name}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{onRemove && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
className="remove-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(att.id || idx);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
bgcolor: 'rgba(0,0,0,0.65)',
|
||||||
|
color: '#fff',
|
||||||
|
opacity: 0.8,
|
||||||
|
p: 0,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(0,0,0,0.9)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
aria-label="Anhang entfernen"
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 13 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document / file chip
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
px: 1,
|
||||||
|
py: 0.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
maxWidth: 220,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getAttachmentIcon(att.type, att.name, false)}
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={att.name}
|
||||||
|
>
|
||||||
|
{att.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatFileSize(att.size)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{onRemove && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(att.id || idx);
|
||||||
|
}}
|
||||||
|
sx={{ p: 0.2, ml: 0.25 }}
|
||||||
|
aria-label="Anhang entfernen"
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,16 @@ import AddRoundedIcon from '@mui/icons-material/AddRounded';
|
|||||||
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||||
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
|
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
|
||||||
|
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Badge from '@mui/material/Badge';
|
||||||
|
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||||
|
|
||||||
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
||||||
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
||||||
import ChatFlowList from './ChatFlowList';
|
import ChatFlowList from './ChatFlowList';
|
||||||
import AskUserPrompt from './AskUserPrompt';
|
import AskUserPrompt from './AskUserPrompt';
|
||||||
|
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
|
||||||
|
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
|
||||||
import {
|
import {
|
||||||
BLUE_GLOW,
|
BLUE_GLOW,
|
||||||
SHADOW_DRAWER,
|
SHADOW_DRAWER,
|
||||||
@@ -50,10 +56,15 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
sendError: s.sendError,
|
sendError: s.sendError,
|
||||||
activeTurns: s.activeTurns || [],
|
activeTurns: s.activeTurns || [],
|
||||||
inputText: '',
|
inputText: '',
|
||||||
|
stagedAttachments: [],
|
||||||
|
isProcessingFiles: false,
|
||||||
|
isDraggingFiles: false,
|
||||||
|
attachmentError: null,
|
||||||
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
|
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
};
|
};
|
||||||
this.inputRef = React.createRef();
|
this.inputRef = React.createRef();
|
||||||
|
this.fileInputRef = React.createRef();
|
||||||
this.focusTimeout = null;
|
this.focusTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,17 +215,114 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
handleSend = async (e) => {
|
handleSend = async (e) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const text = this.state.inputText.trim();
|
const text = this.state.inputText.trim();
|
||||||
if (!text || this.state.conversationState === 'busy' || this.state.isSending) {
|
const attachments = this.state.stagedAttachments || [];
|
||||||
|
if ((!text && attachments.length === 0) || this.state.conversationState === 'busy' || this.state.isSending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState({ inputText: '' });
|
this.setState({ inputText: '', stagedAttachments: [], attachmentError: null });
|
||||||
try {
|
try {
|
||||||
await chatAutomat.actions.sendMessage(text);
|
await chatAutomat.actions.sendMessage(text, attachments);
|
||||||
} catch {
|
} catch {
|
||||||
// Error is tracked in chatAutomat state (sendError)
|
// Error is tracked in chatAutomat state (sendError)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleOpenFileDialog = () => {
|
||||||
|
this.fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFileSelect = (e) => {
|
||||||
|
const files = e.target?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
void this.handleFiles(Array.from(files));
|
||||||
|
}
|
||||||
|
if (e.target) {
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFiles = async (fileList) => {
|
||||||
|
if (!fileList || fileList.length === 0) return;
|
||||||
|
this.setState({ isProcessingFiles: true, attachmentError: null });
|
||||||
|
|
||||||
|
const newAttachments = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const file of fileList) {
|
||||||
|
try {
|
||||||
|
const att = await readFileAttachment(file);
|
||||||
|
newAttachments.push(att);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err.message || `Datei "${file.name}" konnte nicht verarbeitet werden.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState((prev) => ({
|
||||||
|
stagedAttachments: [...prev.stagedAttachments, ...newAttachments],
|
||||||
|
isProcessingFiles: false,
|
||||||
|
attachmentError: errors.length > 0 ? errors.join(' ') : null,
|
||||||
|
}));
|
||||||
|
this.focusInput(50);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRemoveAttachment = (idOrIdx) => {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
stagedAttachments: prev.stagedAttachments.filter((a, idx) => (a.id ? a.id !== idOrIdx : idx !== idOrIdx)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePaste = (e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items || items.length === 0) return;
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
void this.handleFiles(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragEnter = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.dataTransfer?.types?.includes('Files')) {
|
||||||
|
this.setState({ isDraggingFiles: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragOver = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!this.state.isDraggingFiles && e.dataTransfer?.types?.includes('Files')) {
|
||||||
|
this.setState({ isDraggingFiles: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragLeave = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||||
|
this.setState({ isDraggingFiles: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setState({ isDraggingFiles: false });
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
void this.handleFiles(Array.from(files));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
handleKeyDown = (e) => {
|
handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -449,26 +557,36 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
draftConversation,
|
draftConversation,
|
||||||
inputText,
|
inputText,
|
||||||
sendError,
|
sendError,
|
||||||
|
stagedAttachments,
|
||||||
|
isProcessingFiles,
|
||||||
|
isDraggingFiles,
|
||||||
|
attachmentError,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const pendingAsk = this.getPendingAskUser();
|
const pendingAsk = this.getPendingAskUser();
|
||||||
const isBusy = conversationState === 'busy';
|
const isBusy = conversationState === 'busy';
|
||||||
const isWorking = isBusy || isSending;
|
const isWorking = isBusy || isSending;
|
||||||
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
|
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
|
||||||
const canSend = !isWorking && canChat && Boolean(inputText.trim());
|
const canSend = !isWorking && canChat && (Boolean(inputText.trim()) || stagedAttachments.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="form"
|
component="form"
|
||||||
onSubmit={this.handleSend}
|
onSubmit={this.handleSend}
|
||||||
|
onDragEnter={this.handleDragEnter}
|
||||||
|
onDragOver={this.handleDragOver}
|
||||||
|
onDragLeave={this.handleDragLeave}
|
||||||
|
onDrop={this.handleDrop}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: 2,
|
||||||
borderTop: 1,
|
borderTop: 1,
|
||||||
borderColor: 'divider',
|
borderColor: isDraggingFiles ? 'primary.main' : 'divider',
|
||||||
bgcolor: 'background.paper',
|
bgcolor: isDraggingFiles ? 'action.hover' : 'background.paper',
|
||||||
|
transition: 'border-color 0.15s, background-color 0.15s',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sendError && (
|
{sendError && (
|
||||||
@@ -495,7 +613,104 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{attachmentError && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'warning.light',
|
||||||
|
color: 'warning.contrastText',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption">{attachmentError}</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.setState({ attachmentError: null })}
|
||||||
|
sx={{ color: 'inherit', p: 0.2 }}
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Staged attachments tray */}
|
||||||
|
<ChatStagedAttachmentsTray
|
||||||
|
attachments={stagedAttachments}
|
||||||
|
onRemove={this.handleRemoveAttachment}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isProcessingFiles && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, px: 1 }}>
|
||||||
|
<CircularProgress size={14} thickness={5} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.72rem' }}>
|
||||||
|
Dateien werden eingelesen…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDraggingFiles && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
borderRadius: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
bgcolor: 'action.selected',
|
||||||
|
color: 'primary.main',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML)
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
|
||||||
|
{/* Attachment Icon Button */}
|
||||||
|
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={this.handleOpenFileDialog}
|
||||||
|
disabled={!canChat || isWorking}
|
||||||
|
aria-label="Dateien anhängen"
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
color: stagedAttachments.length > 0 ? 'primary.main' : 'text.secondary',
|
||||||
|
bgcolor: stagedAttachments.length > 0 ? 'action.selected' : 'transparent',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: stagedAttachments.length > 0 ? 'primary.main' : 'transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
color: 'primary.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge badgeContent={stagedAttachments.length} color="primary">
|
||||||
|
<AttachFileRoundedIcon sx={{ fontSize: 22 }} />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Hidden File Input */}
|
||||||
|
<input
|
||||||
|
ref={this.fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={ACCEPT_STRING}
|
||||||
|
onChange={this.handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
inputRef={this.inputRef}
|
inputRef={this.inputRef}
|
||||||
multiline
|
multiline
|
||||||
@@ -504,11 +719,14 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
value={inputText}
|
value={inputText}
|
||||||
onChange={(e) => this.setState({ inputText: e.target.value })}
|
onChange={(e) => this.setState({ inputText: e.target.value })}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
|
onPaste={this.handlePaste}
|
||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
placeholder={
|
placeholder={
|
||||||
pendingAsk
|
pendingAsk
|
||||||
? 'Bitte Frage oben beantworten…'
|
? 'Bitte Frage oben beantworten…'
|
||||||
: undefined
|
: stagedAttachments.length > 0
|
||||||
|
? 'Nachricht hinzufügen oder direkt absenden…'
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -527,6 +745,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
|
flexShrink: 0,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: 'primary.dark',
|
bgcolor: 'primary.dark',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import EscalatorWarningIcon from '@mui/icons-material/EscalatorWarning';
|
|||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
|
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
|
||||||
import HealingMarkdown from './HealingMarkdown';
|
import HealingMarkdown from './HealingMarkdown';
|
||||||
|
import { VfsAttachmentLink, parseAttachmentsFromText } from './ChatAttachmentItems';
|
||||||
import {
|
import {
|
||||||
CHROMA,
|
CHROMA,
|
||||||
SHADOW_BUBBLE_ASSISTANT,
|
SHADOW_BUBBLE_ASSISTANT,
|
||||||
@@ -725,12 +726,13 @@ export function normalizeChatMessages(rawMessages = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseMessagePayload(msg.message);
|
const payload = parseMessagePayload(msg.message);
|
||||||
|
const userText = payload.content !== undefined ? payload.content : msg.message;
|
||||||
|
|
||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
flushAssistant();
|
flushAssistant();
|
||||||
result.push({
|
result.push({
|
||||||
...msg,
|
...msg,
|
||||||
message: payload.content !== undefined ? payload.content : msg.message,
|
message: userText,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -839,7 +841,17 @@ function MessageRow({ msg, showTime = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseMessagePayload(msg.message);
|
const payload = parseMessagePayload(msg.message);
|
||||||
const textContent = payload.content !== undefined ? payload.content : msg.message;
|
let textContent = payload.content !== undefined ? payload.content : msg.message;
|
||||||
|
if (typeof textContent === 'string' && textContent.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const nested = JSON.parse(textContent);
|
||||||
|
if (nested && typeof nested === 'object' && nested.content !== undefined) {
|
||||||
|
textContent = nested.content;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { attachments: vfsAttachments, cleanText } = parseAttachmentsFromText(textContent);
|
||||||
|
|
||||||
if (msg.role === 'system') {
|
if (msg.role === 'system') {
|
||||||
return (
|
return (
|
||||||
@@ -917,11 +929,34 @@ function MessageRow({ msg, showTime = true }) {
|
|||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isUser ? (
|
{vfsAttachments.length > 0 && (
|
||||||
textContent
|
<Box
|
||||||
) : (
|
sx={{
|
||||||
<HealingMarkdown text={textContent} streaming={msg.streaming} />
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 0.75,
|
||||||
|
mb: cleanText ? 0.75 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{vfsAttachments.map((att, idx) => (
|
||||||
|
<VfsAttachmentLink
|
||||||
|
key={att.path || idx}
|
||||||
|
name={att.name}
|
||||||
|
path={att.path}
|
||||||
|
type={att.type}
|
||||||
|
isUser={isUser}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{cleanText ? (
|
||||||
|
isUser ? (
|
||||||
|
cleanText
|
||||||
|
) : (
|
||||||
|
<HealingMarkdown text={cleanText} streaming={msg.streaming} />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TABLE_HEADER_PAIR,
|
TABLE_HEADER_PAIR,
|
||||||
byMode,
|
byMode,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
|
import { VfsAttachmentLink, parseAttachmentTag } from './ChatAttachmentItems';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Healing markdown renderer.
|
* Healing markdown renderer.
|
||||||
@@ -522,6 +523,28 @@ function parseInline(src, keyPrefix = 'i') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (c === '[') {
|
if (c === '[') {
|
||||||
|
// Attachment tag: [ATTACHMENT name="..." path="..." type=...]
|
||||||
|
if (src.startsWith('[ATTACHMENT', i)) {
|
||||||
|
const closeTag = src.indexOf(']', i + 11);
|
||||||
|
if (closeTag !== -1) {
|
||||||
|
const attrStr = src.slice(i + 11, closeTag);
|
||||||
|
const parsed = parseAttachmentTag(attrStr);
|
||||||
|
if (parsed.name || parsed.path) {
|
||||||
|
push(
|
||||||
|
<VfsAttachmentLink
|
||||||
|
key={key()}
|
||||||
|
name={parsed.name}
|
||||||
|
path={parsed.path}
|
||||||
|
type={parsed.type}
|
||||||
|
isUser={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
i = closeTag + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const close = src.indexOf(']', i + 1);
|
const close = src.indexOf(']', i + 1);
|
||||||
if (close !== -1) {
|
if (close !== -1) {
|
||||||
const label = src.slice(i + 1, close);
|
const label = src.slice(i + 1, close);
|
||||||
|
|||||||
198
src/components/chat/chatAttachments.js
Normal file
198
src/components/chat/chatAttachments.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* @file chatAttachments.js
|
||||||
|
* @description Helpers for handling file attachments in the AI chat window.
|
||||||
|
* Supports: images, PDFs, CSV, plain text, XML, and HTML.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SUPPORTED_CATEGORIES = {
|
||||||
|
IMAGE: 'image',
|
||||||
|
PDF: 'pdf',
|
||||||
|
CSV: 'csv',
|
||||||
|
TEXT: 'text',
|
||||||
|
XML: 'xml',
|
||||||
|
HTML: 'html',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACCEPT_STRING = [
|
||||||
|
'image/*',
|
||||||
|
'application/pdf',
|
||||||
|
'.pdf',
|
||||||
|
'text/csv',
|
||||||
|
'.csv',
|
||||||
|
'text/plain',
|
||||||
|
'.txt',
|
||||||
|
'.md',
|
||||||
|
'.log',
|
||||||
|
'text/xml',
|
||||||
|
'application/xml',
|
||||||
|
'.xml',
|
||||||
|
'text/html',
|
||||||
|
'.html',
|
||||||
|
'.htm',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the attachment category based on MIME type or file extension.
|
||||||
|
* @param {File|{ name: string, type?: string }} file
|
||||||
|
* @returns {'image'|'pdf'|'csv'|'text'|'xml'|'html'|'unknown'}
|
||||||
|
*/
|
||||||
|
export function categorizeFile(file) {
|
||||||
|
const name = (file?.name || '').toLowerCase();
|
||||||
|
const type = (file?.type || '').toLowerCase();
|
||||||
|
|
||||||
|
// Images
|
||||||
|
if (type.startsWith('image/') || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/.test(name)) {
|
||||||
|
return SUPPORTED_CATEGORIES.IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDFs
|
||||||
|
if (type === 'application/pdf' || name.endsWith('.pdf')) {
|
||||||
|
return SUPPORTED_CATEGORIES.PDF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV
|
||||||
|
if (
|
||||||
|
type === 'text/csv' ||
|
||||||
|
type === 'application/csv' ||
|
||||||
|
type === 'text/comma-separated-values' ||
|
||||||
|
name.endsWith('.csv')
|
||||||
|
) {
|
||||||
|
return SUPPORTED_CATEGORIES.CSV;
|
||||||
|
}
|
||||||
|
|
||||||
|
// XML
|
||||||
|
if (type === 'text/xml' || type === 'application/xml' || name.endsWith('.xml')) {
|
||||||
|
return SUPPORTED_CATEGORIES.XML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML
|
||||||
|
if (type === 'text/html' || name.endsWith('.html') || name.endsWith('.htm')) {
|
||||||
|
return SUPPORTED_CATEGORIES.HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain Text / Markdown / Log
|
||||||
|
if (
|
||||||
|
type.startsWith('text/') ||
|
||||||
|
type === 'application/json' ||
|
||||||
|
/\.(txt|md|log|json|ini|env|yaml|yml)$/.test(name)
|
||||||
|
) {
|
||||||
|
return SUPPORTED_CATEGORIES.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable format of file size in B, KB, or MB.
|
||||||
|
* @param {number} bytes
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes) {
|
||||||
|
if (!bytes || Number.isNaN(bytes) || bytes <= 0) return '0 B';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a browser File object and convert it to a structured attachment object.
|
||||||
|
* @param {File} file
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function readFileAttachment(file) {
|
||||||
|
const category = categorizeFile(file);
|
||||||
|
if (category === 'unknown') {
|
||||||
|
throw new Error(`Das Dateiformat von "${file.name}" wird nicht unterstützt. Erlaubt sind Bilder, PDFs, CSV, Text, XML und HTML.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
throw new Error(`Die Datei "${file.name}" ist mit ${formatFileSize(file.size)} zu groß (maximal 25 MB).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
// For text-based formats, read as text and dataUrl
|
||||||
|
const isTextBased =
|
||||||
|
category === SUPPORTED_CATEGORIES.CSV ||
|
||||||
|
category === SUPPORTED_CATEGORIES.TEXT ||
|
||||||
|
category === SUPPORTED_CATEGORIES.XML ||
|
||||||
|
category === SUPPORTED_CATEGORIES.HTML;
|
||||||
|
|
||||||
|
const dataUrl = await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = () => reject(new Error(`Fehler beim Lesen der Datei "${file.name}"`));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
let textContent = '';
|
||||||
|
let previewSnippet = '';
|
||||||
|
|
||||||
|
if (isTextBased) {
|
||||||
|
try {
|
||||||
|
textContent = await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = () => reject(new Error(`Fehler beim Lesen von "${file.name}" als Text`));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
// First 25 lines or 2000 chars as preview
|
||||||
|
previewSnippet = textContent.slice(0, 2000);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal if text reading fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
mimeType: file.type || getMimeTypeFallback(category, file.name),
|
||||||
|
category,
|
||||||
|
dataUrl,
|
||||||
|
textContent: textContent || undefined,
|
||||||
|
previewSnippet: previewSnippet || undefined,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback MIME type based on extension/category.
|
||||||
|
*/
|
||||||
|
function getMimeTypeFallback(category, filename) {
|
||||||
|
const ext = (filename.split('.').pop() || '').toLowerCase();
|
||||||
|
switch (category) {
|
||||||
|
case SUPPORTED_CATEGORIES.IMAGE:
|
||||||
|
if (ext === 'png') return 'image/png';
|
||||||
|
if (ext === 'webp') return 'image/webp';
|
||||||
|
if (ext === 'svg') return 'image/svg+xml';
|
||||||
|
if (ext === 'gif') return 'image/gif';
|
||||||
|
return 'image/jpeg';
|
||||||
|
case SUPPORTED_CATEGORIES.PDF:
|
||||||
|
return 'application/pdf';
|
||||||
|
case SUPPORTED_CATEGORIES.CSV:
|
||||||
|
return 'text/csv';
|
||||||
|
case SUPPORTED_CATEGORIES.XML:
|
||||||
|
return 'application/xml';
|
||||||
|
case SUPPORTED_CATEGORIES.HTML:
|
||||||
|
return 'text/html';
|
||||||
|
default:
|
||||||
|
return 'text/plain';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger download of an attachment in the browser.
|
||||||
|
* @param {object} attachment
|
||||||
|
*/
|
||||||
|
export function downloadAttachment(attachment) {
|
||||||
|
if (!attachment) return;
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = attachment.dataUrl || (attachment.url ? attachment.url : '');
|
||||||
|
link.download = attachment.name || 'anhang';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
categoryAutomat,
|
categoryAutomat,
|
||||||
} from '../automats';
|
} from '../automats';
|
||||||
import { BrandLabel } from '../components/brand';
|
import { BrandLabel } from '../components/brand';
|
||||||
|
import { SCRIM_LABEL } from '../colors';
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region Styles & Measure Sample
|
// #region Styles & Measure Sample
|
||||||
@@ -66,6 +67,8 @@ const resultRowSx = {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
height: '22px',
|
height: '22px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
'& .MuiListItemText-secondary': {
|
'& .MuiListItemText-secondary': {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -579,22 +582,72 @@ class ArtikelSearchPage extends Component {
|
|||||||
<Box>
|
<Box>
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<List component={Paper} variant="outlined" disablePadding>
|
<List component={Paper} variant="outlined" disablePadding>
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => {
|
||||||
<ListItemButton
|
const isParent = Boolean(row.isParent || row.is_parent);
|
||||||
key={row.id}
|
const childCount = Number(row.childCount) || 0;
|
||||||
type="button"
|
const showChildBadge = isParent || childCount > 0;
|
||||||
divider={index < rows.length - 1}
|
const price = row.price ?? row.net ?? row.vknetto;
|
||||||
data-result-row
|
return (
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
<ListItemButton
|
||||||
onClick={() => {
|
key={row.id}
|
||||||
this.persistQuery();
|
type="button"
|
||||||
this.props.navigate(artikelDetailPath(row));
|
divider={index < rows.length - 1}
|
||||||
}}
|
data-result-row
|
||||||
sx={resultRowSx}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
onClick={() => {
|
||||||
<ListItemText
|
this.persistQuery();
|
||||||
primary={row.name}
|
this.props.navigate(artikelDetailPath(row));
|
||||||
secondary={
|
}}
|
||||||
|
sx={resultRowSx}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.name}
|
||||||
|
</Box>
|
||||||
|
{showChildBadge && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
title={`${childCount} Varianten`}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: SCRIM_LABEL,
|
||||||
|
color: 'common.white',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{childCount}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
<Typography
|
<Typography
|
||||||
component="span"
|
component="span"
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@@ -641,10 +694,11 @@ class ArtikelSearchPage extends Component {
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
|
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
|
||||||
>
|
>
|
||||||
{row.price != null ? formatPrice(row.price) : '—'}
|
{price != null ? formatPrice(price) : '—'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user