This commit is contained in:
seb
2026-10-10 05:53:51 +02:00
parent 9bc878eb51
commit 6af150aea4
9 changed files with 534 additions and 5 deletions

View File

@@ -7,7 +7,7 @@ description: >-
# Dash API Routes Overview (All)
Overview of all 206 API endpoints across all modules.
Overview of all 207 API endpoints across all modules.
### Notation
@@ -689,6 +689,9 @@ _none_
#### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: str, query: download?: bool, raw?: bool`
- Returns: `200 str`
#### Execute a virtual filesystem applet — `POST /vfs/{*path}`
- Params: `path: str, body?: { input?: {}, timeout?: int, max_output?: int }`
- Returns: `200 { ok: bool, result: any|null, stdout: str, consoleLines: int, durationMs: int, truncated?: bool, truncationNote?: str }`
#### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}`
- Params: `path: str, body: { content: str, mime?: str }`
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`

View File

@@ -0,0 +1,157 @@
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
/**
* @file VfsAppletForm.jsx
* @description Schema-driven form renderer for applet input/output schemas.
* Renders one field per `schema.properties` entry. Supports string, number,
* integer, boolean, array and object types. Array/object values are edited
* as JSON text. In `readOnly` mode all fields are disabled (result preview).
*/
export default class VfsAppletForm extends PureComponent {
handleFieldChange = (key, nextVal) => {
const { values, onChange } = this.props;
if (!onChange) return;
onChange({ ...(values || {}), [key]: nextVal });
};
handleJsonTextChange = (key, type, rawText) => {
// Try to keep parsed JSON in state; fall back to raw text while typing.
const trimmed = String(rawText || '').trim();
if (trimmed === '') {
this.handleFieldChange(key, type === 'array' ? [] : {});
return;
}
try {
this.handleFieldChange(key, JSON.parse(rawText));
} catch {
this.handleFieldChange(key, rawText);
}
};
renderField = (key, prop) => {
const { values, readOnly } = this.props;
const raw = values?.[key];
const type = prop?.type || 'string';
const title = prop?.title || key;
const required = Array.isArray(this.props.schema?.required)
&& this.props.schema.required.includes(key);
const label = required ? `${title} *` : title;
if (type === 'boolean') {
return (
<FormControlLabel
key={key}
control={
<Switch
size="small"
checked={Boolean(raw)}
disabled={Boolean(readOnly)}
onChange={(e) => this.handleFieldChange(key, e.target.checked)}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={600} sx={{ fontSize: '0.82rem' }}>
{label}
</Typography>
{prop?.description && (
<Typography variant="caption" color="text.secondary">
{prop.description}
</Typography>
)}
</Box>
}
sx={{ m: 0, py: 0.25 }}
/>
);
}
if (type === 'number' || type === 'integer') {
return (
<TextField
key={key}
fullWidth
size="small"
type="number"
label={label}
value={raw === undefined || raw === null ? '' : raw}
disabled={Boolean(readOnly)}
helperText={prop?.description || (prop?.default !== undefined ? `Standard: ${String(prop.default)}` : '')}
onChange={(e) => {
const v = e.target.value;
if (v === '') {
this.handleFieldChange(key, '');
} else {
const num = Number(v);
this.handleFieldChange(key, Number.isNaN(num) ? v : num);
}
}}
sx={{ '& .MuiInputBase-input': { fontSize: '0.85rem' } }}
/>
);
}
if (type === 'array' || type === 'object') {
const text = typeof raw === 'string' ? raw : JSON.stringify(raw ?? (type === 'array' ? [] : {}), null, 2);
return (
<TextField
key={key}
fullWidth
size="small"
multiline
minRows={2}
maxRows={8}
label={`${label} (JSON)`}
value={text}
disabled={Boolean(readOnly)}
helperText={prop?.description || ''}
onChange={(e) => this.handleJsonTextChange(key, type, e.target.value)}
sx={{
'& .MuiInputBase-input': { fontFamily: 'monospace', fontSize: '0.78rem' },
}}
/>
);
}
// Default: string (also covers enum -> plain text for now)
return (
<TextField
key={key}
fullWidth
size="small"
label={label}
value={raw === undefined || raw === null ? '' : String(raw)}
disabled={Boolean(readOnly)}
helperText={prop?.description || (prop?.default !== undefined ? `Standard: ${String(prop.default)}` : '')}
onChange={(e) => this.handleFieldChange(key, e.target.value)}
inputProps={{ autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.85rem' } }}
/>
);
};
render() {
const { schema, emptyHint } = this.props;
const props = schema?.properties;
const keys = props && typeof props === 'object' ? Object.keys(props) : [];
if (keys.length === 0) {
return (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.8rem' }}>
{emptyHint || 'Keine Felder im Schema definiert.'}
</Typography>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{keys.map((key) => this.renderField(key, props[key]))}
</Box>
);
}
}

View File

@@ -0,0 +1,196 @@
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Divider from '@mui/material/Divider';
import Alert from '@mui/material/Alert';
import CircularProgress from '@mui/material/CircularProgress';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import InputRoundedIcon from '@mui/icons-material/InputRounded';
import OutputRoundedIcon from '@mui/icons-material/OutputRounded';
import VfsFileIcon from './VfsFileIcon';
import VfsAppletForm from './VfsAppletForm';
import { parseAppletSchemas, buildAppletDefaults } from './vfsUtils';
/**
* @file VfsAppletRunner.jsx
* @description UI shell for `*.apple.js` applet files (forms-only, no execution yet).
* Shows input form (editable) and output form (read-only) parsed from
* `// @applet-input-schema {...}` / `// @applet-output-schema {...}` markers,
* plus a Run button that intentionally does nothing for now.
*/
export default class VfsAppletRunner extends PureComponent {
constructor(props) {
super(props);
const { inputSchema, outputSchema } = parseAppletSchemas(props.content || '');
this.state = {
inputSchema,
outputSchema,
inputValues: buildAppletDefaults(inputSchema),
// Output preview starts with schema defaults (read-only, no execution yet).
outputValues: buildAppletDefaults(outputSchema),
};
}
componentDidUpdate(prevProps) {
if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) {
const { inputSchema, outputSchema } = parseAppletSchemas(this.props.content || '');
this.setState({
inputSchema,
outputSchema,
inputValues: buildAppletDefaults(inputSchema),
outputValues: buildAppletDefaults(outputSchema),
});
}
}
handleInputChange = (nextValues) => {
this.setState({ inputValues: nextValues });
};
// Intentionally a no-op: execution will be wired up in a later step.
handleRun = () => {};
render() {
const { fileName, file, loadingContent, onClose, onShowCode, onMentionInChat } = this.props;
const { inputSchema, outputSchema, inputValues, outputValues } = this.state;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Runner Header */}
<Box
sx={{
p: 1.25,
px: 2,
borderBottom: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: 'background.paper',
flexShrink: 0,
minHeight: 48,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, mr: 1 }}>
<IconButton size="small" onClick={onClose} aria-label="Zurück zur Dateiliste" sx={{ p: 0.5, mr: 0.25 }}>
<ArrowBackRoundedIcon fontSize="small" />
</IconButton>
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
<Typography
variant="body2"
fontWeight={700}
noWrap
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
>
{fileName}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
{onShowCode && (
<Button
size="small"
variant="outlined"
startIcon={<CodeRoundedIcon />}
onClick={onShowCode}
sx={{ borderRadius: 2, fontSize: '0.78rem', fontWeight: 600 }}
>
Code
</Button>
)}
{onMentionInChat && (
<IconButton size="small" onClick={onMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}>
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
</IconButton>
)}
<IconButton size="small" onClick={onClose} aria-label="Runner schließen" sx={{ p: 0.6, ml: 0.5 }}>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* Runner Content */}
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
{loadingContent ? (
<Box sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1.5, color: 'text.secondary' }}>
<CircularProgress size={24} />
<Typography variant="body2">Lade Applet…</Typography>
</Box>
) : (
<>
{/* Input Section */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<InputRoundedIcon color="primary" sx={{ fontSize: 20 }} />
<Typography variant="subtitle2" fontWeight={700}>
Eingabe
</Typography>
</Box>
<VfsAppletForm
schema={inputSchema}
values={inputValues}
onChange={this.handleInputChange}
emptyHint="Kein @applet-input-schema im Dateikopf gefunden."
/>
</Box>
<Divider />
{/* Run Button (no-op for now) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Button
variant="contained"
startIcon={<PlayArrowRoundedIcon />}
onClick={this.handleRun}
title="Ausführung folgt in einem späteren Schritt"
sx={{ borderRadius: 2, fontWeight: 700 }}
>
Ausführen
</Button>
<Typography variant="caption" color="text.secondary">
Führt das Applet noch nicht aus (nur Formular-Vorschau).
</Typography>
</Box>
<Divider />
{/* Output Section (read-only preview) */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<OutputRoundedIcon color="action" sx={{ fontSize: 20 }} />
<Typography variant="subtitle2" fontWeight={700}>
Ergebnis
</Typography>
</Box>
<VfsAppletForm
schema={outputSchema}
values={outputValues}
readOnly
emptyHint="Kein @applet-output-schema im Dateikopf gefunden."
/>
</Box>
{!inputSchema && !outputSchema && (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
In dieser Datei wurden keine Applet-Schemas gefunden. Erwartet werden Kopfzeilen wie
<Box component="code" sx={{ display: 'block', mt: 0.5, fontFamily: 'monospace', fontSize: '0.72rem' }}>
{'// @applet-input-schema {"type":"object","properties":{...}}'}
<br />
{'// @applet-output-schema {"type":"object","properties":{...}}'}
</Box>
</Alert>
)}
</>
)}
</Box>
</Box>
);
}
}

View File

@@ -9,7 +9,9 @@ import vfsAutomat from '../../automats/vfsAutomat';
import VfsFileExplorer from './VfsFileExplorer';
import VfsTextEditor from './VfsTextEditor';
import VfsFilePreview from './VfsFilePreview';
import VfsAppletRunner from './VfsAppletRunner';
import VfsNewFileDialog from './VfsNewFileDialog';
import { isAppletFile } from './vfsUtils';
export default class VfsEditorPanel extends PureComponent {
constructor(props) {
@@ -31,6 +33,8 @@ export default class VfsEditorPanel extends PureComponent {
searchQuery: s.searchQuery,
newFileDialogOpen: s.newFileDialogOpen,
newFileDefaultFolder: s.newFileDefaultFolder,
// Applet runner view: 'run' shows input/output forms, 'code' shows text editor.
appletView: 'run',
};
}
@@ -58,6 +62,15 @@ export default class VfsEditorPanel extends PureComponent {
}
}
componentDidUpdate(prevProps, prevState) {
// Reset to runner view whenever another file gets selected.
if (prevState.selectedFileName !== this.state.selectedFileName) {
if (this.state.appletView !== 'run') {
this.setState({ appletView: 'run' });
}
}
}
componentWillUnmount() {
this.unsub?.();
}
@@ -86,6 +99,13 @@ export default class VfsEditorPanel extends PureComponent {
}
};
handleRunApplet = (fileName) => {
if (!fileName) return;
// Switch to runner view first so the forms show immediately on select.
this.setState({ appletView: 'run' });
void vfsAutomat.actions.selectFile(fileName);
};
render() {
const {
files,
@@ -100,8 +120,12 @@ export default class VfsEditorPanel extends PureComponent {
saveError,
searchQuery,
newFileDialogOpen,
appletView,
} = this.state;
const isApplet = Boolean(selectedFileName && isAppletFile(selectedFileName));
const showAppletRunner = isApplet && selectedFile?.isText && appletView === 'run';
return (
<Box
sx={{
@@ -115,7 +139,18 @@ export default class VfsEditorPanel extends PureComponent {
borderColor: 'divider',
}}
>
{selectedFileName && selectedFile?.isText ? (
{showAppletRunner ? (
/* Applet Runner Mode: Input/Output forms + no-op Run button */
<VfsAppletRunner
fileName={selectedFileName}
file={selectedFile}
content={fileContent}
loadingContent={loadingContent}
onClose={() => vfsAutomat.actions.selectFile(null)}
onShowCode={() => this.setState({ appletView: 'code' })}
onMentionInChat={() => this.handleMentionInChat(selectedFile)}
/>
) : selectedFileName && selectedFile?.isText ? (
/* Single Column Mode: Text Editor */
<VfsTextEditor
fileName={selectedFileName}
@@ -131,6 +166,9 @@ export default class VfsEditorPanel extends PureComponent {
onClose={() => vfsAutomat.actions.selectFile(null)}
onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)}
onMentionInChat={(lineInfo) => this.handleMentionInChat(selectedFile, lineInfo)}
// Applets get an exe affordance to switch back to the runner forms.
isApplet={isApplet}
onShowRunner={isApplet ? () => this.setState({ appletView: 'run' }) : undefined}
/>
) : selectedFileName && !selectedFile?.isText ? (
/* Single Column Mode: Preview (Image, PDF, Binary) */
@@ -181,6 +219,7 @@ export default class VfsEditorPanel extends PureComponent {
selectedFileName={selectedFileName}
searchQuery={searchQuery}
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
onRunApplet={this.handleRunApplet}
onRefresh={() => vfsAutomat.actions.loadFiles()}
onOpenNewDialog={(parentDir) => vfsAutomat.actions.setNewFileDialogOpen(true, parentDir)}
onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)}

View File

@@ -25,9 +25,10 @@ import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import CreateNewFolderRoundedIcon from '@mui/icons-material/CreateNewFolderRounded';
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import VfsFileIcon from './VfsFileIcon';
import { formatVfsSize, formatVfsDate } from './vfsUtils';
import { formatVfsSize, formatVfsDate, isAppletFile } from './vfsUtils';
import VfsNewFolderDialog from './VfsNewFolderDialog';
import VfsRenameDialog from './VfsRenameDialog';
@@ -415,8 +416,9 @@ export default class VfsFileExplorer extends PureComponent {
};
renderFileNode = (file, depth) => {
const { selectedFileName, onSelectFile } = this.props;
const { selectedFileName, onSelectFile, onRunApplet } = this.props;
const isSelected = file.path === selectedFileName;
const isApplet = isAppletFile(file.path);
return (
<Box
@@ -486,6 +488,23 @@ export default class VfsFileExplorer extends PureComponent {
'&:hover': { opacity: 1 },
}}
>
{isApplet && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
if (onRunApplet) {
onRunApplet(file.path);
} else {
onSelectFile(file.path);
}
}}
aria-label="Applet ausf\u00fchren"
sx={{ p: 0.5, color: 'success.main' }}
>
<PlayArrowRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
)}
<IconButton
size="small"
onClick={(e) => this.handleOpenMenu(e, file.fileData)}

View File

@@ -8,9 +8,15 @@ import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import ArticleRoundedIcon from '@mui/icons-material/ArticleRounded';
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
import { getVfsCategory } from './vfsUtils';
const CATEGORY_CONFIG = {
applet: {
icon: SmartToyRoundedIcon,
color: '#00897b', // Teal
label: 'Applet',
},
skill: {
icon: AutoAwesomeRoundedIcon,
color: '#9c27b0', // Purple

View File

@@ -10,6 +10,7 @@ import UndoRoundedIcon from '@mui/icons-material/UndoRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import Alert from '@mui/material/Alert';
import VfsFileIcon from './VfsFileIcon';
@@ -153,6 +154,8 @@ export default class VfsTextEditor extends PureComponent {
onSave,
onClose,
onMentionInChat,
isApplet,
onShowRunner,
} = this.props;
const lineCount = (content || '').split('\n').length;
@@ -211,6 +214,24 @@ export default class VfsTextEditor extends PureComponent {
{/* Action Buttons */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
{isApplet && onShowRunner && (
<Button
size="small"
variant="outlined"
color="success"
startIcon={<PlayArrowRoundedIcon />}
onClick={onShowRunner}
sx={{
borderRadius: 2,
px: 1.5,
py: 0.4,
fontSize: '0.78rem',
fontWeight: 700,
}}
>
Exe
</Button>
)}
{onMentionInChat && (
<IconButton size="small" onClick={this.handleMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}>
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />

View File

@@ -3,4 +3,6 @@ export { default as VfsFileExplorer } from './VfsFileExplorer';
export { default as VfsTextEditor } from './VfsTextEditor';
export { default as VfsFilePreview } from './VfsFilePreview';
export { default as VfsFileIcon } from './VfsFileIcon';
export { default as VfsAppletForm } from './VfsAppletForm';
export { default as VfsAppletRunner } from './VfsAppletRunner';
export * from './vfsUtils';

View File

@@ -41,12 +41,13 @@ export function formatVfsDate(isoString) {
* Detect category of file.
* @param {string} filePath
* @param {string} [mime]
* @returns {'code'|'table'|'markdown'|'text'|'pdf'|'image'|'skill'|'binary'}
* @returns {'code'|'table'|'markdown'|'text'|'pdf'|'image'|'skill'|'applet'|'binary'}
*/
export function getVfsCategory(filePath, mime = '') {
const p = String(filePath || '').toLowerCase();
const m = String(mime || '').toLowerCase();
if (isAppletFile(p)) return 'applet';
if (p.endsWith('.skill')) return 'skill';
if (p.endsWith('.pdf') || m === 'application/pdf') return 'pdf';
if (
@@ -79,6 +80,17 @@ export function getVfsCategory(filePath, mime = '') {
return 'binary';
}
/**
* Check whether a VFS path is an executable applet file.
* Applets are `*.apple.js` (also accepts `*.applet.js`).
* @param {string} filePath
* @returns {boolean}
*/
export function isAppletFile(filePath) {
const p = String(filePath || '').toLowerCase();
return p.endsWith('.apple.js') || p.endsWith('.applet.js');
}
/**
* Split a VFS file path into directory and basename.
* @param {string} path
@@ -95,3 +107,77 @@ export function splitVfsPath(path) {
base: clean.slice(lastSlash + 1),
};
}
/**
* Extract a JSON schema object from a `// @applet-*-schema {...}` marker line.
* @param {string} content
* @param {'input'|'output'} kind
* @returns {object|null}
*/
function extractAppletSchema(content, kind) {
const marker = kind === 'input' ? '@applet-input-schema' : '@applet-output-schema';
const lines = String(content || '').split('\n');
for (const line of lines) {
const idx = line.indexOf(marker);
if (idx === -1) continue;
const jsonPart = line.slice(idx + marker.length).trim();
if (!jsonPart) continue;
try {
const parsed = JSON.parse(jsonPart);
if (parsed && typeof parsed === 'object') return parsed;
} catch {
// ignore malformed schema lines
}
}
return null;
}
/**
* Parse applet input/output JSON schemas from file content.
* Looks for `// @applet-input-schema {...}` and `// @applet-output-schema {...}`.
* @param {string} content
* @returns {{ inputSchema: object|null, outputSchema: object|null }}
*/
export function parseAppletSchemas(content) {
return {
inputSchema: extractAppletSchema(content, 'input'),
outputSchema: extractAppletSchema(content, 'output'),
};
}
/**
* Build a default values object from a JSON schema (`properties` + `default`).
* @param {object|null} schema
* @returns {object}
*/
export function buildAppletDefaults(schema) {
const values = {};
const props = schema?.properties;
if (!props || typeof props !== 'object') return values;
for (const key of Object.keys(props)) {
const prop = props[key] || {};
if (prop.default !== undefined) {
values[key] = prop.default;
continue;
}
switch (prop.type) {
case 'boolean':
values[key] = false;
break;
case 'number':
case 'integer':
values[key] = '';
break;
case 'array':
values[key] = [];
break;
case 'object':
values[key] = {};
break;
default:
values[key] = '';
break;
}
}
return values;
}