This commit is contained in:
seb
2026-10-10 06:33:46 +02:00
parent 6af150aea4
commit 004e0de869
3 changed files with 421 additions and 61 deletions

View File

@@ -9,6 +9,7 @@ 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 DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import InputRoundedIcon from '@mui/icons-material/InputRounded';
@@ -16,36 +17,53 @@ import OutputRoundedIcon from '@mui/icons-material/OutputRounded';
import VfsFileIcon from './VfsFileIcon';
import VfsAppletForm from './VfsAppletForm';
import { parseAppletSchemas, buildAppletDefaults } from './vfsUtils';
import { parseAppletSchemas, buildAppletDefaults, parseCsvRows } from './vfsUtils';
import { vfs as vfsApi } from '../../api';
/**
* @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.
* @description Runner UI for applet files (`*.applet.js`).
* Shows an editable input form (from the `// @applet-input-schema {...}` JSON Schema) and a
* read-only result table (from the `// @applet-output-schema name:type;…` typed CSV header).
* The Run button executes the applet via `POST /api/vfs/{path}`; the response carries the
* result as a typed CSV string plus `columns`, `rows` and optional `warnings`.
*/
export default class VfsAppletRunner extends PureComponent {
constructor(props) {
super(props);
const { inputSchema, outputSchema } = parseAppletSchemas(props.content || '');
const { inputSchema, outputColumns } = parseAppletSchemas(props.content || '');
this.state = {
inputSchema,
outputSchema,
outputColumns,
inputValues: buildAppletDefaults(inputSchema),
// Output preview starts with schema defaults (read-only, no execution yet).
outputValues: buildAppletDefaults(outputSchema),
isRunning: false,
runError: null,
runResult: null,
runColumns: null,
runRows: [],
runWarnings: [],
stdout: '',
durationMs: null,
truncated: false,
};
}
componentDidUpdate(prevProps) {
if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) {
const { inputSchema, outputSchema } = parseAppletSchemas(this.props.content || '');
const { inputSchema, outputColumns } = parseAppletSchemas(this.props.content || '');
this.setState({
inputSchema,
outputSchema,
outputColumns,
inputValues: buildAppletDefaults(inputSchema),
outputValues: buildAppletDefaults(outputSchema),
isRunning: false,
runError: null,
runResult: null,
runColumns: null,
runRows: [],
runWarnings: [],
stdout: '',
durationMs: null,
truncated: false,
});
}
}
@@ -54,12 +72,179 @@ export default class VfsAppletRunner extends PureComponent {
this.setState({ inputValues: nextValues });
};
// Intentionally a no-op: execution will be wired up in a later step.
handleRun = () => {};
/**
* Download the last run's CSV result as a file named after the applet.
*/
handleDownloadCsv = () => {
const { fileName } = this.props;
const { runResult } = this.state;
if (!runResult) return;
const base = String(fileName || 'applet').split('/').pop().replace(/\.applet\.js$/i, '');
const blob = new Blob([runResult], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${base || 'applet'}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
/**
* Execute the applet via `POST /api/vfs/{path}` and render the returned CSV table.
*/
handleRun = async () => {
const { fileName } = this.props;
const { inputValues, isRunning } = this.state;
if (!fileName || isRunning) return;
this.setState({ isRunning: true, runError: null, stdout: '', truncated: false });
try {
const res = await vfsApi.runApplet(fileName, { input: inputValues });
const csv = typeof res?.result === 'string' ? res.result : '';
// The response only carries the row COUNT, so read the cells back out of the CSV.
const parsed = parseCsvRows(csv);
const header = parsed.length > 0 ? parsed[0] : [];
const body = parsed.slice(1);
const columns = Array.isArray(res?.columns) && res.columns.length > 0
? res.columns
: header.map((name) => ({ name, type: 'string' }));
this.setState({
isRunning: false,
runResult: csv,
runColumns: columns,
runRows: body,
runWarnings: Array.isArray(res?.warnings) ? res.warnings : [],
stdout: res?.stdout || '',
durationMs: res?.durationMs ?? null,
truncated: Boolean(res?.truncated),
});
} catch (err) {
this.setState({
isRunning: false,
runError: err?.message || 'Ausführung fehlgeschlagen',
runResult: null,
runColumns: null,
runRows: [],
runWarnings: [],
});
}
};
renderResultTable() {
const { runColumns, runRows, outputColumns } = this.state;
// Before the first run there are no result columns yet, so fall back to the columns
// declared in the file header — the table then shows its shape immediately.
const columns = runColumns && runColumns.length > 0 ? runColumns : outputColumns;
if (!columns || columns.length === 0) {
return (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.8rem' }}>
Kein @applet-output-schema im Dateikopf gefunden.
</Typography>
);
}
return (
<Box sx={{ overflowX: 'auto', border: 1, borderColor: 'divider', borderRadius: 2 }}>
<Box
component="table"
sx={{
borderCollapse: 'collapse',
width: '100%',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '0.78rem',
}}
>
<Box component="thead">
<Box component="tr" sx={{ bgcolor: 'action.hover' }}>
{columns.map((col) => (
<Box
component="th"
key={col.name}
sx={{
textAlign: 'left',
px: 1.25,
py: 0.75,
borderBottom: 1,
borderColor: 'divider',
whiteSpace: 'nowrap',
}}
>
<Typography variant="caption" fontWeight={700} sx={{ fontSize: '0.75rem' }}>
{col.name}
</Typography>
<Typography
variant="caption"
color="text.disabled"
sx={{ display: 'block', fontSize: '0.65rem', fontWeight: 400 }}
>
{col.type}
</Typography>
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{runRows.length === 0 ? (
<Box component="tr">
<Box
component="td"
colSpan={columns.length}
sx={{ px: 1.25, py: 1.5, color: 'text.secondary', textAlign: 'center' }}
>
Noch nicht ausgeführt
</Box>
</Box>
) : (
runRows.map((row, rowIndex) => (
<Box
component="tr"
key={rowIndex}
sx={{ '&:nth-of-type(odd)': { bgcolor: 'action.hover' } }}
>
{columns.map((col, colIndex) => (
<Box
component="td"
key={col.name}
sx={{
px: 1.25,
py: 0.6,
borderBottom: 1,
borderColor: 'divider',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{row[colIndex] ?? ''}
</Box>
))}
</Box>
))
)}
</Box>
</Box>
</Box>
);
}
render() {
const { fileName, file, loadingContent, onClose, onShowCode, onMentionInChat } = this.props;
const { inputSchema, outputSchema, inputValues, outputValues } = this.state;
const {
inputSchema,
outputColumns,
inputValues,
isRunning,
runError,
runResult,
runWarnings,
stdout,
durationMs,
truncated,
} = this.state;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -143,47 +328,105 @@ export default class VfsAppletRunner extends PureComponent {
<Divider />
{/* Run Button (no-op for now) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{/* Run Button */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Button
variant="contained"
startIcon={<PlayArrowRoundedIcon />}
startIcon={isRunning ? <CircularProgress size={16} color="inherit" /> : <PlayArrowRoundedIcon />}
onClick={this.handleRun}
title="Ausführung folgt in einem späteren Schritt"
disabled={isRunning || !fileName}
sx={{ borderRadius: 2, fontWeight: 700 }}
>
Ausführen
{isRunning ? 'Läuft…' : 'Ausführen'}
</Button>
<Typography variant="caption" color="text.secondary">
Führt das Applet noch nicht aus (nur Formular-Vorschau).
</Typography>
{durationMs != null && !isRunning && (
<Typography variant="caption" color="text.secondary">
{durationMs} ms
</Typography>
)}
{truncated && (
<Typography variant="caption" color="warning.main">
stdout gekürzt
</Typography>
)}
</Box>
{/* Run error */}
{runError && (
<Alert severity="error" sx={{ fontSize: '0.8rem' }}>
{runError}
</Alert>
)}
{/* stdout (only when the applet logged something) */}
{stdout && (
<Box>
<Typography variant="caption" fontWeight={700} color="text.secondary">
Konsole
</Typography>
<Box
component="pre"
sx={{
m: 0,
mt: 0.5,
p: 1.5,
borderRadius: 2,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#0f1215' : '#f0f2f5'),
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '0.75rem',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 220,
overflow: 'auto',
}}
>
{stdout}
</Box>
</Box>
)}
<Divider />
{/* Output Section (read-only preview) */}
{/* Output Section (typed CSV result table) */}
<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>
{runResult && (
<Button
size="small"
variant="outlined"
startIcon={<DownloadRoundedIcon />}
onClick={this.handleDownloadCsv}
sx={{ ml: 'auto', borderRadius: 2, fontSize: '0.75rem', fontWeight: 600 }}
>
CSV herunterladen
</Button>
)}
</Box>
<VfsAppletForm
schema={outputSchema}
values={outputValues}
readOnly
emptyHint="Kein @applet-output-schema im Dateikopf gefunden."
/>
{/* Warnings: cells dropped for not matching their declared type. */}
{runWarnings.length > 0 && (
<Alert severity="warning" sx={{ fontSize: '0.78rem', mb: 1.5 }}>
{runWarnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</Alert>
)}
{this.renderResultTable()}
</Box>
{!inputSchema && !outputSchema && (
{!inputSchema && !outputColumns && (
<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":{...}}'}
{'// @applet-output-schema id:integer;name:string;active:boolean'}
</Box>
</Alert>
)}

View File

@@ -82,13 +82,13 @@ export function getVfsCategory(filePath, mime = '') {
/**
* Check whether a VFS path is an executable applet file.
* Applets are `*.apple.js` (also accepts `*.applet.js`).
* Applets are `*.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');
return p.endsWith('.applet.js');
}
/**
@@ -109,40 +109,136 @@ export function splitVfsPath(path) {
}
/**
* Extract a JSON schema object from a `// @applet-*-schema {...}` marker line.
* @param {string} content
* @param {'input'|'output'} kind
* @returns {object|null}
* Column types an applet may declare in its `@applet-output-schema` header.
* Mirrors `APPLET_COLUMN_TYPES` in `dashback/src/tools/javascript.js`.
*/
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
}
export const APPLET_COLUMN_TYPES = ['string', 'integer', 'number', 'boolean', 'date'];
/** Semicolon-separated columns, matching the backend and the `*Csv.js` spreadsheet exports. */
export const APPLET_CSV_SEPARATOR = ';';
/**
* Parse an applet output header into typed CSV columns.
*
* The header carries column names AND types, e.g. `id:integer;name:string;active:boolean`.
* A bare `name` (no `:type`) defaults to `string`. Returns `null` when the header is empty,
* declares an unknown type, repeats a name, or has an empty column name — the same cases the
* backend rejects with a `ToolError`.
*
* @param {string} header Raw text after the `// @applet-output-schema ` marker.
* @returns {Array<{ name: string, type: string }>|null}
*/
export function parseTypedCsvColumns(header) {
const text = String(header || '').trim();
if (!text) return null;
const columns = [];
const seen = new Set();
for (const raw of text.split(APPLET_CSV_SEPARATOR)) {
const field = raw.trim();
if (!field) continue;
const sep = field.lastIndexOf(':');
const name = (sep === -1 ? field : field.slice(0, sep)).trim();
const type = (sep === -1 ? 'string' : field.slice(sep + 1).trim().toLowerCase());
if (!name) return null;
if (!APPLET_COLUMN_TYPES.includes(type)) return null;
if (seen.has(name)) return null;
seen.add(name);
columns.push({ name, type });
}
return null;
return columns.length > 0 ? columns : 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 }}
* Parse a `;`-separated CSV string (RFC 4180 quoting) into a 2D array of raw cell strings.
*
* Used to render the applet's `result` table; the response only carries the row COUNT, so the
* cells themselves have to be read back out of the CSV string.
*
* @param {string} text CSV text, header row first.
* @param {string} [separator=';']
* @returns {string[][]}
*/
export function parseCsvRows(text, separator = APPLET_CSV_SEPARATOR) {
const source = String(text ?? '');
if (!source) return [];
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
if (inQuotes) {
if (ch === '"') {
if (source[i + 1] === '"') {
field += '"';
i += 1;
} else {
inQuotes = false;
}
} else {
field += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === separator) {
row.push(field);
field = '';
} else if (ch === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
} else if (ch !== '\r') {
field += ch;
}
}
// Flush the trailing field/row, but ignore the empty row a final newline would produce.
if (field !== '' || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
/**
* Parse applet metadata from the file header.
*
* Mirrors the backend's `readApplet()`: the input JSON Schema must be on line 1 and the typed
* CSV output columns on line 2. Parsing the same two lines keeps the UI in lockstep with what
* the sandbox will actually accept.
*
* @param {string} content Applet file source.
* @returns {{ inputSchema: object|null, outputColumns: Array<{name: string, type: string}>|null }}
*/
export function parseAppletSchemas(content) {
return {
inputSchema: extractAppletSchema(content, 'input'),
outputSchema: extractAppletSchema(content, 'output'),
};
const lines = String(content || '').replace(/^\uFEFF/, '').split(/\r?\n/);
let inputSchema = null;
const inputMatch = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/);
if (inputMatch) {
try {
const parsed = JSON.parse(inputMatch[1]);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) inputSchema = parsed;
} catch {
// Malformed input schema: leave null so the UI surfaces the hint.
}
}
let outputColumns = null;
const outputMatch = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/);
if (outputMatch) {
outputColumns = parseTypedCsvColumns(outputMatch[1]);
}
return { inputSchema, outputColumns };
}
/**