diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index bdaf042..7e335b7 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -7,7 +7,7 @@ description: >- # Dash API Routes Overview (All) -Overview of all 248 API endpoints across all modules. +Overview of all 250 API endpoints across all modules. ### Notation @@ -919,6 +919,9 @@ _none_ #### List virtual filesystem files — `GET /vfs` _none_ - Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }` +#### Restore system default applets — `POST /vfs/applets/restore` +_none_ +- Returns: `200 { success: bool, restored: str[], count: int }` #### Respond to a frontend draft request — `POST /vfs/drafts/response` - Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }` - Returns: `200 { success: bool }` @@ -928,6 +931,9 @@ _none_ #### Rename or move a virtual filesystem file or directory — `POST /vfs/rename` - Params: `body: { oldPath: str, newPath: str }` - Returns: `200 { success: bool, oldPath: str, newPath: str }` +#### Restore system default skills — `POST /vfs/skills/restore` +_none_ +- Returns: `200 { success: bool, restored: str[], count: int }` #### Upload files to virtual filesystem — `POST /vfs/upload` - Params: `form: { files: str[], targetDir?: str }` - Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }` diff --git a/src/api/vfs.js b/src/api/vfs.js index aacf7db..270fcaa 100644 --- a/src/api/vfs.js +++ b/src/api/vfs.js @@ -181,4 +181,32 @@ export const vfs = { } }, // #endregion + + /** + * POST /api/vfs/skills/restore + * Restores all system default .SKILL files into the company's VFS (overwrites existing files). + * Requires no request body. Emits vfs SSE event. + * → 200 { success: true, restored: string[], count: number } + */ + // #region POST /api/vfs/skills/restore — restoreSkills + restoreSkills() { + return http('/api/vfs/skills/restore', { + method: 'POST', + }); + }, + // #endregion + + /** + * POST /api/vfs/applets/restore + * Restores all system default .applet.js example applets into the company's VFS (overwrites existing files). + * Requires no request body. Emits vfs SSE event. + * → 200 { success: true, restored: string[], count: number } + */ + // #region POST /api/vfs/applets/restore — restoreApplets + restoreApplets() { + return http('/api/vfs/applets/restore', { + method: 'POST', + }); + }, + // #endregion }; diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js index 524ee87..d93fda5 100644 --- a/src/automats/chatAutomat.js +++ b/src/automats/chatAutomat.js @@ -331,20 +331,18 @@ const actions = { /** * Stage an extracted text snippet (e.g. from an invoice or delivery document) as a pill. - * @param {{ text: string, size?: number, label?: string }} snippet + * @param {{ text: string, label?: string }} snippet */ - insertExtractionSnippet({ text = '', size, label } = {}) { + insertExtractionSnippet({ text = '', label = 'parsed invoice' } = {}) { const trimmed = String(text || '').trim(); if (!trimmed) return; - const finalSize = Number(size) || new Blob([trimmed]).size; chatOpenedThisSession = true; chatAutomat.setState({ isOpen: true, sseEstablished: true, pendingExtraction: { text: trimmed, - size: finalSize, - label, + label: label || 'parsed invoice', }, }); ensureCompanyEventsConnected(); @@ -356,26 +354,17 @@ const actions = { /** * Open the chat drawer and append text / prompt to the chat composer. - * If the prompt is an extracted text snippet (e.g. `Text aus „...“:`), - * automatically converts it into a staged extraction pill. * @param {string} prompt */ insertPrompt(prompt = '') { - chatOpenedThisSession = true; - const trimmedPrompt = String(prompt || '').trim(); - const extractionMatch = trimmedPrompt.match(/^(?:Text for Data Extraction|Text aus „[^“]+“):\s*```(?:\w+)?\n([\s\S]*?)```$/); - if (extractionMatch) { - const content = (extractionMatch[1] || '').trim(); - if (content) { - chatAutomat.actions.insertExtractionSnippet({ text: content }); - return; - } - } + const text = String(prompt || '').trim(); + if (!text) return; + chatOpenedThisSession = true; chatAutomat.setState((prev) => ({ isOpen: true, sseEstablished: true, - pendingInput: prev.pendingInput ? `${prev.pendingInput}\n\n${prompt}` : prompt, + pendingInput: prev.pendingInput ? `${prev.pendingInput}\n\n${text}` : text, })); ensureCompanyEventsConnected(); if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) { diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx index 0ac0bbd..a9ce16d 100644 --- a/src/components/chat/ChatDrawer.jsx +++ b/src/components/chat/ChatDrawer.jsx @@ -15,6 +15,7 @@ import SendRoundedIcon from '@mui/icons-material/SendRounded'; import AddRoundedIcon from '@mui/icons-material/AddRounded'; import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; +import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import Tooltip from '@mui/material/Tooltip'; import Badge from '@mui/material/Badge'; @@ -115,6 +116,15 @@ export default class ChatDrawer extends PureComponent { isInputFocused: false, stagedAttachments: [], stagedReferences: [], + stagedExtractions: s.pendingExtraction + ? [ + { + id: `ext-${Date.now()}`, + text: s.pendingExtraction.text, + label: s.pendingExtraction.label || 'parsed invoice', + }, + ] + : [], isProcessingFiles: false, isDraggingFiles: false, attachmentError: null, @@ -178,6 +188,13 @@ export default class ChatDrawer extends PureComponent { } }); } + if (chatAutomat.state.pendingExtraction != null) { + queueMicrotask(() => { + if (chatAutomat.state.pendingExtraction != null) { + chatAutomat.setState({ pendingExtraction: null }); + } + }); + } this.unsub = chatAutomat.subscribe(this, (state) => { const wasOpen = this.state.isOpen; const willOpen = state.isOpen; @@ -211,6 +228,21 @@ export default class ChatDrawer extends PureComponent { } else if (!wasOpen && willOpen) { this.focusInput(0); } + if (state.pendingExtraction != null) { + const ext = state.pendingExtraction; + const newExt = { + id: `ext-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + text: ext.text, + label: ext.label || 'parsed invoice', + }; + patch.stagedExtractions = [...(this.state.stagedExtractions || []), newExt]; + queueMicrotask(() => { + if (chatAutomat.state.pendingExtraction != null) { + chatAutomat.setState({ pendingExtraction: null }); + } + }); + this.focusInput(0); + } return patch; }); @@ -309,6 +341,13 @@ export default class ChatDrawer extends PureComponent { this.focusInput(50); }; + handleRemoveExtraction = (id) => { + this.setState((prev) => ({ + stagedExtractions: (prev.stagedExtractions || []).filter((e) => e.id !== id), + })); + this.focusInput(50); + }; + handleVfsResizerStart = (e) => { if (e.button !== undefined && e.button !== 0) return; e.preventDefault(); @@ -483,9 +522,10 @@ export default class ChatDrawer extends PureComponent { const rawText = this.state.inputText.trim(); const attachments = this.state.stagedAttachments || []; const references = this.state.stagedReferences || []; + const extractions = this.state.stagedExtractions || []; if ( - (!rawText && attachments.length === 0 && references.length === 0) || + (!rawText && attachments.length === 0 && references.length === 0 && extractions.length === 0) || this.state.conversationState === 'busy' || this.state.isSending ) { @@ -493,6 +533,13 @@ export default class ChatDrawer extends PureComponent { } let text = rawText; + if (extractions.length > 0) { + const extMarkdown = extractions + .map((ext) => `\`\`\`\n${ext.text.trim()}\n\`\`\``) + .join('\n\n'); + text = text ? `${extMarkdown}\n\n${text}` : extMarkdown; + } + if (references.length > 0) { const refMarkdown = references .map((ref) => { @@ -522,6 +569,7 @@ export default class ChatDrawer extends PureComponent { inputText: '', stagedAttachments: [], stagedReferences: [], + stagedExtractions: [], attachmentError: null, }); @@ -1265,6 +1313,7 @@ export default class ChatDrawer extends PureComponent { sendError, stagedAttachments, stagedReferences, + stagedExtractions = [], isProcessingFiles, isDraggingFiles, attachmentError, @@ -1277,7 +1326,10 @@ export default class ChatDrawer extends PureComponent { const canSend = !isWorking && canChat && - (Boolean(inputText.trim()) || stagedAttachments.length > 0 || stagedReferences.length > 0); + (Boolean(inputText.trim()) || + stagedAttachments.length > 0 || + stagedReferences.length > 0 || + stagedExtractions.length > 0); return ( - {stagedReferences.length > 0 && ( + {(stagedReferences.length > 0 || (stagedExtractions && stagedExtractions.length > 0)) && ( ))} + {stagedExtractions && stagedExtractions.map((ext) => ( + + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.08)' + : 'rgba(0, 0, 0, 0.05)', + border: '1px solid', + borderColor: (theme) => + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.12)' + : 'rgba(0, 0, 0, 0.1)', + color: 'text.primary', + fontSize: '0.75rem', + lineHeight: 1.2, + userSelect: 'none', + }} + > + + + {ext.label || 'parsed invoice'} + + { + e.stopPropagation(); + this.handleRemoveExtraction(ext.id); + }} + aria-label="Entfernen" + sx={{ + p: 0.2, + ml: 0.1, + color: 'text.secondary', + '&:hover': { + color: 'error.main', + bgcolor: 'action.hover', + }, + }} + > + + + + ))} )} diff --git a/src/components/manifest/Attachment.jsx b/src/components/manifest/Attachment.jsx index b00f4ad..e86d310 100644 --- a/src/components/manifest/Attachment.jsx +++ b/src/components/manifest/Attachment.jsx @@ -1,7 +1,6 @@ import { PureComponent } from 'react'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; @@ -9,7 +8,6 @@ import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineR import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles'; import SkeleHashImage from '../settings/SkeleHashImage'; -import { enqueueSnackbar } from 'notistack'; import { generateTextThumbnail } from '../../utils/textThumbnail'; import chatAutomat from '../../automats/chatAutomat'; import { @@ -107,17 +105,12 @@ export class Attachment extends PureComponent { } const trimmed = (textContent || '').trim(); - if (!trimmed) { - enqueueSnackbar('Datei enthält keinen Text zum Erwähnen im Chat.', { variant: 'warning' }); - return; - } + if (!trimmed) return; - const size = attachment.size || new Blob([trimmed]).size; chatAutomat.actions.insertExtractionSnippet({ text: trimmed, - size, + label: 'parsed invoice', }); - enqueueSnackbar('Text im Chat erwähnt', { variant: 'success' }); }; handleDelete = (e) => { @@ -290,7 +283,6 @@ export class Attachment extends PureComponent { disabled={disabled} onClick={this.handleRescan} aria-label="Zu Text" - title="Zu Text" sx={{ bgcolor: SCRIM_ACTION, color: WHITE, @@ -311,7 +303,6 @@ export class Attachment extends PureComponent { disabled={disabled} onClick={this.handleMentionInChat} aria-label="Im Chat erwähnen" - title="Im Chat erwähnen" sx={{ bgcolor: SCRIM_ACTION, color: WHITE, @@ -331,7 +322,6 @@ export class Attachment extends PureComponent { disabled={disabled} onClick={this.handleDelete} aria-label="Löschen" - title="Löschen" sx={{ bgcolor: SCRIM_ACTION, color: WHITE, diff --git a/src/components/settings/BackupSettingsPanel.jsx b/src/components/settings/BackupSettingsPanel.jsx index 15c615e..5c1aa5a 100644 --- a/src/components/settings/BackupSettingsPanel.jsx +++ b/src/components/settings/BackupSettingsPanel.jsx @@ -21,19 +21,28 @@ import TableRow from '@mui/material/TableRow'; import Tabs from '@mui/material/Tabs'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogActions from '@mui/material/DialogActions'; import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined'; +import CloseIcon from '@mui/icons-material/Close'; import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined'; import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; import RefreshIcon from '@mui/icons-material/Refresh'; import SearchIcon from '@mui/icons-material/Search'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import StorageIcon from '@mui/icons-material/Storage'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined'; import { enqueueSnackbar } from 'notistack'; +import { vfs } from '../../api'; import backupAutomat from '../../automats/backupAutomat'; import { notifyError } from '../../utils/notify'; import { @@ -92,11 +101,18 @@ export default class BackupSettingsPanel extends Component { vfsLoadingByBackup: vfsLoadingByBackup || {}, tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly' expandedItemName: null, + restoringSkills: false, + restoringApplets: false, + confirmRestore: null, // null | 'skills' | 'applets' + lastRestoreResult: null, // null | { type: 'skills' | 'applets', count: number, restored: string[] } }; this.handleRefresh = this.handleRefresh.bind(this); this.handleTierChange = this.handleTierChange.bind(this); this.handleToggleExpand = this.handleToggleExpand.bind(this); + this.handleOpenConfirm = this.handleOpenConfirm.bind(this); + this.handleCloseConfirm = this.handleCloseConfirm.bind(this); + this.handleExecuteRestore = this.handleExecuteRestore.bind(this); } componentDidMount() { @@ -151,6 +167,65 @@ export default class BackupSettingsPanel extends Component { })); } + handleOpenConfirm(type) { + this.setState({ confirmRestore: type }); + } + + handleCloseConfirm() { + if (this.state.restoringSkills || this.state.restoringApplets) return; + this.setState({ confirmRestore: null }); + } + + // #region handleExecuteRestore + // MARK: calls POST /api/vfs/skills/restore or /api/vfs/applets/restore + async handleExecuteRestore() { + const { confirmRestore } = this.state; + if (!confirmRestore) return; + + if (confirmRestore === 'skills') { + this.setState({ restoringSkills: true }); + try { + const res = await vfs.restoreSkills(); + enqueueSnackbar(`${res.count} Standard-Skills (.SKILL) erfolgreich wiederhergestellt.`, { + variant: 'success', + }); + this.setState({ + confirmRestore: null, + restoringSkills: false, + lastRestoreResult: { + type: 'skills', + count: res.count, + restored: res.restored || [], + }, + }); + } catch (err) { + this.setState({ restoringSkills: false }); + notifyError(err); + } + } else if (confirmRestore === 'applets') { + this.setState({ restoringApplets: true }); + try { + const res = await vfs.restoreApplets(); + enqueueSnackbar(`${res.count} Beispiel-Applets (.applet.js) erfolgreich wiederhergestellt.`, { + variant: 'success', + }); + this.setState({ + confirmRestore: null, + restoringApplets: false, + lastRestoreResult: { + type: 'applets', + count: res.count, + restored: res.restored || [], + }, + }); + } catch (err) { + this.setState({ restoringApplets: false }); + notifyError(err); + } + } + } + // #endregion + render() { const { status, @@ -161,6 +236,10 @@ export default class BackupSettingsPanel extends Component { vfsLoadingByBackup, tierTab, expandedItemName, + restoringSkills, + restoringApplets, + confirmRestore, + lastRestoreResult, } = this.state; if (status === 'loading' && !backupStatus) { @@ -224,7 +303,7 @@ export default class BackupSettingsPanel extends Component { size="small" startIcon={} onClick={this.handleRefresh} - disabled={isRunning} + disabled={status === 'loading'} > Aktualisieren @@ -246,19 +325,11 @@ export default class BackupSettingsPanel extends Component { > : } - label={backupStatus?.enabled ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'} - color={backupStatus?.enabled ? 'success' : 'default'} + icon={backupStatus?.enabled && isRunning ? : } + label={backupStatus?.enabled && isRunning ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'} + color={backupStatus?.enabled && isRunning ? 'success' : 'default'} variant="outlined" /> - {isRunning && ( - } - label="Backup wird ausgeführt..." - color="primary" - /> - )} } @@ -431,6 +502,231 @@ export default class BackupSettingsPanel extends Component { )} + + {/* System Defaults Restore Section (VFS Skills & Applets) */} + + + + + + System-Standarddateien wiederherstellen (VFS) + + + + Setzt systemweite Vorlagen im virtuellen Dateisystem (VFS) des Unternehmens auf den Auslieferungszustand zurück. Bereits vorhandene gleichnamige Dateien werden dabei überschrieben. + + + + + + {/* Skills Restore Card */} + + + + + + + + + Standard-Skills + + + + + + Stellt alle systemweiten Standard-.SKILL-Dateien für KI-Assistenten und Workflows im VFS wieder her. + + + + + + Überschreibt existierende Dateien + + + + + + {/* Applets Restore Card */} + + + + + + + + + Beispiel-Applets + + + + + + Stellt alle systemweiten Standard-.applet.js-Beispiel-Applets für Workflows und Auswertungen im VFS wieder her. + + + + + + Überschreibt existierende Dateien + + + + + + + {/* Last restore results notification / chip display */} + {lastRestoreResult && ( + + + + Erfolgreich wiederhergestellt ({lastRestoreResult.count} {lastRestoreResult.type === 'skills' ? 'Skills' : 'Applets'}): + + this.setState({ lastRestoreResult: null })} + sx={{ p: 0.25, color: 'text.secondary' }} + > + + + + + {lastRestoreResult.restored.map((name) => ( + + ))} + + + )} + + + + {/* Confirmation Dialog for System Defaults Restore */} + + + + {confirmRestore === 'skills' + ? 'Standard-Skills wiederherstellen?' + : 'Beispiel-Applets wiederherstellen?'} + + + + {confirmRestore === 'skills' + ? 'Möchten Sie alle systemweiten Standard-.SKILL-Dateien im VFS des aktuellen Unternehmens wiederherstellen? Bestehende .SKILL-Dateien werden dabei überschrieben.' + : 'Möchten Sie alle systemweiten Standard-.applet.js-Beispiel-Applets im VFS des aktuellen Unternehmens wiederherstellen? Bestehende .applet.js-Dateien werden dabei überschrieben.'} + + + + + + + ); } diff --git a/src/routes/lieferung/neu.jsx b/src/routes/lieferung/neu.jsx index 573a3b9..4e23548 100644 --- a/src/routes/lieferung/neu.jsx +++ b/src/routes/lieferung/neu.jsx @@ -659,20 +659,17 @@ class LieferungNeuPage extends PureComponent { const existingTxt = this.state.attachments?.find( (a) => a.parentPdfId === attachment.id || a.name === txtName, ); - let targetTxt = existingTxt; if (existingTxt) { - const updated = { + this.automat.actions.updateAttachment({ id: existingTxt.id, file: txtFile, size: txtFile.size, text, thumbnail: txtThumbnail || existingTxt.thumbnail, isDirty: true, - }; - this.automat.actions.updateAttachment(updated); - targetTxt = { ...existingTxt, ...updated }; + }); } else { - targetTxt = { + this.automat.actions.addAttachment({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: txtFile, name: txtFile.name, @@ -682,14 +679,10 @@ class LieferungNeuPage extends PureComponent { text, parentPdfId: attachment.id, uploadedAt: new Date().toISOString(), - }; - this.automat.actions.addAttachment(targetTxt); + }); } - this.handleAttachmentClick(targetTxt); - enqueueSnackbar('Text aus PDF extrahiert und geöffnet', { variant: 'success' }); } catch (txtErr) { console.error('Fehler beim Aktualisieren der Textdatei:', txtErr); - enqueueSnackbar('Fehler beim Extrahieren von Text aus der PDF', { variant: 'error' }); } } } @@ -715,17 +708,12 @@ class LieferungNeuPage extends PureComponent { } const trimmed = (text || '').trim(); - if (!trimmed) { - enqueueSnackbar('Datei enthält keinen Text zum Erwähnen im Chat.', { variant: 'warning' }); - return; - } + if (!trimmed) return; - const size = attachment.size || new Blob([trimmed]).size; chatAutomat.actions.insertExtractionSnippet({ text: trimmed, - size, + label: 'parsed invoice', }); - enqueueSnackbar('Text im Chat erwähnt', { variant: 'success' }); } handlePdfDelete(attachment) { diff --git a/test/dummyBackend.mjs b/test/dummyBackend.mjs index 66ce651..61c3b19 100644 --- a/test/dummyBackend.mjs +++ b/test/dummyBackend.mjs @@ -505,6 +505,12 @@ const server = http.createServer(async (req, res) => { if (p === '/api/vfs/upload' && method === 'POST') { return json(res, 200, { files: [] }); } + if (p === '/api/vfs/skills/restore' && method === 'POST') { + return json(res, 200, { success: true, restored: ['system.SKILL'], count: 1 }); + } + if (p === '/api/vfs/applets/restore' && method === 'POST') { + return json(res, 200, { success: true, restored: ['example.applet.js'], count: 1 }); + } if (p.startsWith('/api/vfs/') && method === 'PUT') { return json(res, 200, { ok: true }); }