u
This commit is contained in:
909
package-lock.json
generated
909
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^18.0.5",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^5.2.1",
|
||||
"google-auth-library": "^11.1.0",
|
||||
"ink": "^8.0.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"libllmorbiter": "file:../llmorbiter",
|
||||
"multer": "^2.4.0",
|
||||
"pdfconvert-wasm": "^0.3.0",
|
||||
"pdfkit": "^0.20.2",
|
||||
"react": "^19.3.0",
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"selfsigned": "^5.5.0",
|
||||
|
||||
206
src/defaultSkills/exceljs.SKILL
Normal file
206
src/defaultSkills/exceljs.SKILL
Normal file
@@ -0,0 +1,206 @@
|
||||
ExcelJS Spreadsheet Guide: Reading, Writing, Formatting, and VFS Integration
|
||||
|
||||
# Goal
|
||||
Provide actionable instructions, code patterns, and best practices for creating, reading, modifying, and styling Excel (`.xlsx`) and CSV spreadsheets in the `javascript_execute` sandbox using `ExcelJS`.
|
||||
|
||||
# Environment & Globals
|
||||
- Both `ExcelJS` and `exceljs` are pre-injected in global scope. Do NOT use `require()` or `import`.
|
||||
- `Workbook`, `stream.xlsx.WorkbookWriter`, and `stream.xlsx.WorkbookReader` are pre-bridged to the Virtual Filesystem (VFS).
|
||||
- Wrap async scripts in an IIFE: `module.exports = (async () => { ... })();`.
|
||||
|
||||
---
|
||||
|
||||
# 1. Creating and Populating a Workbook
|
||||
|
||||
```javascript
|
||||
module.exports = (async () => {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'QuixPOS Assistant';
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet('Articles', {
|
||||
properties: { tabColor: { argb: 'FF1F4E79' } },
|
||||
views: [{ state: 'frozen', xSplit: 0, ySplit: 1 }] // freeze header row
|
||||
});
|
||||
|
||||
// Define column schemas
|
||||
ws.columns = [
|
||||
{ header: 'SKU', key: 'sku', width: 14 },
|
||||
{ header: 'Title', key: 'title', width: 36 },
|
||||
{ header: 'Price (Gross)', key: 'price', width: 14, style: { numFmt: '#,##0.00 €' } },
|
||||
{ header: 'Stock', key: 'stock', width: 10, style: { numFmt: '#,##0' } },
|
||||
{ header: 'Supplier SKU', key: 'supplierSku', width: 16 }
|
||||
];
|
||||
|
||||
// Add rows via object matching column keys
|
||||
ws.addRow({ sku: 'ART-1001', title: 'Espresso Arabica Premium', price: 14.99, stock: 45, supplierSku: 'SUP-01' });
|
||||
ws.addRow({ sku: 'ART-1002', title: 'Earl Grey Organic Tea', price: 8.50, stock: 20, supplierSku: 'SUP-02' });
|
||||
|
||||
// Or add multiple rows at once
|
||||
ws.addRows([
|
||||
{ sku: 'ART-1003', title: 'Single Origin Colombia', price: 16.20, stock: 12, supplierSku: 'SUP-03' },
|
||||
{ sku: 'ART-1004', title: 'Matcha Ceremonial Grade', price: 24.00, stock: 8, supplierSku: 'SUP-04' }
|
||||
]);
|
||||
|
||||
// Save directly to VFS
|
||||
await wb.xlsx.writeFile('articles-export.xlsx');
|
||||
|
||||
return { success: true, rows: ws.rowCount, file: 'articles-export.xlsx' };
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. Header & Cell Styling
|
||||
|
||||
```javascript
|
||||
// Header styling: bold white text with dark blue background
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.height = 24;
|
||||
headerRow.font = { name: 'Arial', size: 11, bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
headerRow.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF1F4E79' }
|
||||
};
|
||||
headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
|
||||
// Zebra striping data rows
|
||||
for (let i = 2; i <= ws.rowCount; i++) {
|
||||
const row = ws.getRow(i);
|
||||
row.height = 20;
|
||||
row.alignment = { vertical: 'middle' };
|
||||
if (i % 2 === 0) {
|
||||
row.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFF9FAFB' }
|
||||
};
|
||||
}
|
||||
// Thin borders
|
||||
row.eachCell({ includeEmpty: true }, (cell) => {
|
||||
cell.border = {
|
||||
bottom: { style: 'thin', color: { argb: 'FFE5E7EB' } }
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. Formulas & Summary Rows
|
||||
|
||||
```javascript
|
||||
// Add a total row at the end
|
||||
const totalRow = ws.addRow({
|
||||
title: 'Total Stock & Value',
|
||||
price: { formula: 'SUMPRODUCT(C2:C5, D2:D5)', result: 0 },
|
||||
stock: { formula: 'SUM(D2:D5)', result: 85 }
|
||||
});
|
||||
|
||||
totalRow.font = { bold: true };
|
||||
totalRow.getCell('title').alignment = { horizontal: 'right' };
|
||||
totalRow.border = {
|
||||
top: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
bottom: { style: 'double', color: { argb: 'FF000000' } }
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. Reading & Modifying Existing Spreadsheets from VFS
|
||||
|
||||
```javascript
|
||||
module.exports = (async () => {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
// Path is relative to VFS (e.g. user attachment or previous artifact)
|
||||
await wb.xlsx.readFile('attachments/inventory.xlsx');
|
||||
|
||||
const ws = wb.getWorksheet(1); // 1-based index or name 'Sheet1'
|
||||
const records = [];
|
||||
|
||||
ws.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
||||
if (rowNumber === 1) return; // skip header
|
||||
records.push({
|
||||
sku: row.getCell(1).text,
|
||||
name: row.getCell(2).text,
|
||||
price: Number(row.getCell(3).value) || 0,
|
||||
stock: Number(row.getCell(4).value) || 0
|
||||
});
|
||||
});
|
||||
|
||||
// You can modify cells and write back
|
||||
ws.getCell('C2').value = 19.99;
|
||||
await wb.xlsx.writeFile('attachments/inventory-updated.xlsx');
|
||||
|
||||
return { totalRead: records.length, records };
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 5. Working with CSV Files
|
||||
|
||||
```javascript
|
||||
// Read CSV from VFS
|
||||
const csvWb = new ExcelJS.Workbook();
|
||||
await csvWb.csv.readFile('data.csv');
|
||||
const sheet = csvWb.getWorksheet(1);
|
||||
|
||||
// Write CSV to VFS
|
||||
await csvWb.csv.writeFile('export.csv');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. Streaming Massive Datasets
|
||||
|
||||
For large tables (>5,000 rows) use `WorkbookWriter` to prevent high memory consumption:
|
||||
|
||||
```javascript
|
||||
module.exports = (async () => {
|
||||
const writer = new ExcelJS.stream.xlsx.WorkbookWriter({ filename: 'large-catalog.xlsx' });
|
||||
const ws = writer.addWorksheet('Catalog');
|
||||
|
||||
ws.columns = [
|
||||
{ header: 'ID', key: 'id', width: 10 },
|
||||
{ header: 'Name', key: 'name', width: 30 },
|
||||
{ header: 'Stock', key: 'stock', width: 12 }
|
||||
];
|
||||
|
||||
for (let i = 1; i <= 10000; i++) {
|
||||
ws.addRow({ id: i, name: `Product ${i}`, stock: Math.floor(Math.random() * 100) }).commit();
|
||||
}
|
||||
|
||||
ws.commit();
|
||||
await writer.commit();
|
||||
|
||||
return { exportedRows: 10000, file: 'large-catalog.xlsx' };
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 7. Embedding Product / Company Images
|
||||
|
||||
```javascript
|
||||
// Image filename is automatically resolved from VFS
|
||||
const logoId = wb.addImage({
|
||||
filename: 'images/logo.png',
|
||||
extension: 'png'
|
||||
});
|
||||
|
||||
// Position image by cell coordinates
|
||||
ws.addImage(logoId, {
|
||||
tl: { col: 5, row: 1 },
|
||||
ext: { width: 120, height: 40 }
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 8. Presenting Output to User
|
||||
|
||||
When you generate or modify an Excel file in `javascript_execute`, **always include a clickable Markdown link in your response summary**:
|
||||
- `[Download Articles Spreadsheet](articles-export.xlsx)`
|
||||
- `[Download Updated Inventory](attachments/inventory-updated.xlsx)`
|
||||
274
src/defaultSkills/pdfkit.SKILL
Normal file
274
src/defaultSkills/pdfkit.SKILL
Normal file
@@ -0,0 +1,274 @@
|
||||
PDFKit PDF Generation Guide: Documents, Styling, Layout, and VFS Integration
|
||||
|
||||
# Goal
|
||||
Provide actionable instructions, code patterns, and best practices for creating beautiful, production-ready PDF documents (invoices, reports, labels, catalogs) in the `javascript_execute` sandbox using `PDFKit`.
|
||||
|
||||
# Environment & Globals
|
||||
- `PDFDocument`, `PDFKit`, and `pdfkit` are pre-injected in global scope. Do NOT use `require()` or `import`.
|
||||
- Instances of `PDFDocument` provide built-in VFS file writing: `await doc.toFile('path/to/file.pdf')` or `const buf = await doc.toBuffer()`.
|
||||
- Standard stream piping also works: `doc.pipe(fs.createWriteStream('path.pdf'))`.
|
||||
- Wrap async scripts in an IIFE: `module.exports = (async () => { ... })();`.
|
||||
|
||||
---
|
||||
|
||||
# 1. Quick Start: Simple Document
|
||||
|
||||
```javascript
|
||||
module.exports = (async () => {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||
|
||||
doc.fontSize(22)
|
||||
.fillColor('#1F4E79')
|
||||
.text('QuixPOS Monthly Summary', { align: 'center' });
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(12)
|
||||
.fillColor('#333333')
|
||||
.text('This report summarizes inventory levels and recent sales transactions.');
|
||||
|
||||
// Save directly to VFS
|
||||
await doc.toFile('monthly-summary.pdf');
|
||||
|
||||
return { file: 'monthly-summary.pdf' };
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. Typography, Colors & Standard Fonts
|
||||
|
||||
PDFKit comes with 14 standard built-in fonts that require no external font files:
|
||||
- **Helvetica**: `'Helvetica'`, `'Helvetica-Bold'`, `'Helvetica-Oblique'`, `'Helvetica-BoldOblique'`
|
||||
- **Times**: `'Times-Roman'`, `'Times-Bold'`, `'Times-Italic'`, `'Times-BoldItalic'`
|
||||
- **Courier**: `'Courier'`, `'Courier-Bold'`, `'Courier-Oblique'`, `'Courier-BoldOblique'`
|
||||
|
||||
```javascript
|
||||
// Header
|
||||
doc.font('Helvetica-Bold')
|
||||
.fontSize(20)
|
||||
.fillColor('#1F4E79')
|
||||
.text('INVOICE', 50, 50);
|
||||
|
||||
// Subheaders & meta
|
||||
doc.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.fillColor('#555555')
|
||||
.text('Invoice #: INV-2026-001', 50, 80)
|
||||
.text('Date: 2026-10-06', 50, 95)
|
||||
.text('Due Date: 2026-11-05', 50, 110);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. Vector Shapes, Lines, and Boxes
|
||||
|
||||
Coordinates in PDFKit use points (72 points = 1 inch). A4 dimensions: 595.28 x 841.89 points.
|
||||
|
||||
```javascript
|
||||
// Horizontal rule / separator
|
||||
doc.moveTo(50, 135)
|
||||
.lineTo(545, 135)
|
||||
.strokeColor('#E5E7EB')
|
||||
.lineWidth(1)
|
||||
.stroke();
|
||||
|
||||
// Filled info box with rounded corners
|
||||
doc.roundedRect(50, 150, 495, 60, 4)
|
||||
.fillColor('#F9FAFB')
|
||||
.fill();
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#1F4E79')
|
||||
.text('Billed To:', 65, 160);
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#333333')
|
||||
.text('Acme Retail GmbH\nIndustriestrasse 12, 10115 Berlin\nVAT ID: DE123456789', 65, 175);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. Building Clean Data Tables
|
||||
|
||||
```javascript
|
||||
const tableTop = 230;
|
||||
const colX = {
|
||||
pos: 50,
|
||||
sku: 80,
|
||||
title: 170,
|
||||
qty: 370,
|
||||
price: 430,
|
||||
total: 495
|
||||
};
|
||||
|
||||
// Table Header Background
|
||||
doc.rect(50, tableTop, 495, 20)
|
||||
.fillColor('#1F4E79')
|
||||
.fill();
|
||||
|
||||
// Table Header Text
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#FFFFFF');
|
||||
doc.text('#', colX.pos + 5, tableTop + 5);
|
||||
doc.text('SKU', colX.sku, tableTop + 5);
|
||||
doc.text('Description', colX.title, tableTop + 5);
|
||||
doc.text('Qty', colX.qty, tableTop + 5, { width: 50, align: 'right' });
|
||||
doc.text('Price', colX.price, tableTop + 5, { width: 55, align: 'right' });
|
||||
doc.text('Total', colX.total, tableTop + 5, { width: 50, align: 'right' });
|
||||
|
||||
// Table Rows
|
||||
const items = [
|
||||
{ sku: 'ART-101', title: 'Premium Espresso Roast 1kg', qty: 5, price: 18.50 },
|
||||
{ sku: 'ART-102', title: 'Single Origin Filter Roast 250g', qty: 10, price: 7.20 },
|
||||
{ sku: 'ART-103', title: 'Ceremonial Matcha Tin 100g', qty: 2, price: 24.00 }
|
||||
];
|
||||
|
||||
let y = tableTop + 24;
|
||||
items.forEach((item, index) => {
|
||||
const lineTotal = item.qty * item.price;
|
||||
|
||||
// Zebra striping
|
||||
if (index % 2 === 1) {
|
||||
doc.rect(50, y - 4, 495, 20).fillColor('#F9FAFB').fill();
|
||||
}
|
||||
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#333333');
|
||||
doc.text(String(index + 1), colX.pos + 5, y);
|
||||
doc.text(item.sku, colX.sku, y);
|
||||
doc.text(item.title, colX.title, y, { width: 190, ellipsis: true });
|
||||
doc.text(String(item.qty), colX.qty, y, { width: 50, align: 'right' });
|
||||
doc.text(`${item.price.toFixed(2)} €`, colX.price, y, { width: 55, align: 'right' });
|
||||
doc.text(`${lineTotal.toFixed(2)} €`, colX.total, y, { width: 50, align: 'right' });
|
||||
|
||||
// Underline
|
||||
doc.moveTo(50, y + 16).lineTo(545, y + 16).strokeColor('#F3F4F6').lineWidth(0.5).stroke();
|
||||
y += 20;
|
||||
});
|
||||
|
||||
// Summary block
|
||||
y += 10;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#1F4E79')
|
||||
.text('Total Net:', 380, y, { width: 90, align: 'right' })
|
||||
.text('212.50 €', 480, y, { width: 65, align: 'right' });
|
||||
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#555555')
|
||||
.text('VAT (19%):', 380, y + 15, { width: 90, align: 'right' })
|
||||
.text('40.38 €', 480, y + 15, { width: 65, align: 'right' });
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#1F4E79')
|
||||
.text('Grand Total:', 380, y + 32, { width: 90, align: 'right' })
|
||||
.text('252.88 €', 480, y + 32, { width: 65, align: 'right' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 5. Embedding Images & Logos
|
||||
|
||||
`doc.image()` accepts VFS relative paths (e.g. `'images/logo.png'`), virtual database images, or raw Buffers:
|
||||
|
||||
```javascript
|
||||
// Place company logo in top-right corner
|
||||
try {
|
||||
doc.image('images/logo.png', 425, 45, { width: 120 });
|
||||
} catch {
|
||||
// Graceful fallback if image does not exist
|
||||
doc.font('Helvetica-Bold').fontSize(14).fillColor('#1F4E79').text('QuixPOS', 450, 50);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. Multi-page Documents & Page Numbers
|
||||
|
||||
To add page numbers across all pages, use `{ bufferPages: true }`:
|
||||
|
||||
```javascript
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
|
||||
|
||||
// Page 1 content...
|
||||
doc.text('First Page Content');
|
||||
|
||||
// Add Page 2
|
||||
doc.addPage();
|
||||
doc.text('Second Page Content');
|
||||
|
||||
// Add footer with page numbering to every page before closing
|
||||
const range = doc.bufferedPageRange();
|
||||
for (let i = 0; i < range.count; i++) {
|
||||
doc.switchToPage(i);
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#9CA3AF')
|
||||
.text(
|
||||
`Page ${i + 1} of ${range.count} • Generated by QuixPOS Assistant`,
|
||||
50,
|
||||
800,
|
||||
{ align: 'center', width: 495 }
|
||||
);
|
||||
}
|
||||
|
||||
await doc.toFile('multipage-report.pdf');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 7. Complete End-to-End Invoice Example
|
||||
|
||||
```javascript
|
||||
module.exports = (async () => {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true });
|
||||
|
||||
// Header
|
||||
doc.font('Helvetica-Bold').fontSize(22).fillColor('#1F4E79').text('INVOICE', 50, 50);
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#6B7280')
|
||||
.text('Invoice #: INV-2026-089', 50, 80)
|
||||
.text(`Date: ${new Date().toISOString().slice(0, 10)}`, 50, 95);
|
||||
|
||||
// Client box
|
||||
doc.roundedRect(50, 125, 495, 55, 4).fillColor('#F9FAFB').fill();
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#1F4E79').text('Customer:', 65, 135);
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151')
|
||||
.text('Sample Store GmbH • Friedrichstrasse 45, 10117 Berlin', 65, 150);
|
||||
|
||||
// Items table
|
||||
let y = 205;
|
||||
doc.rect(50, y, 495, 20).fillColor('#1F4E79').fill();
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#FFFFFF')
|
||||
.text('Description', 60, y + 5)
|
||||
.text('Qty', 350, y + 5, { width: 50, align: 'right' })
|
||||
.text('Unit Price', 410, y + 5, { width: 60, align: 'right' })
|
||||
.text('Total', 480, y + 5, { width: 55, align: 'right' });
|
||||
|
||||
y += 24;
|
||||
const items = [
|
||||
{ desc: 'Robusta Espresso Blend 1kg', qty: 4, price: 15.00 },
|
||||
{ desc: 'Single Origin Ethiopia Yirgacheffe 500g', qty: 2, price: 12.50 }
|
||||
];
|
||||
|
||||
let total = 0;
|
||||
for (const item of items) {
|
||||
const lineTotal = item.qty * item.price;
|
||||
total += lineTotal;
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#1F2937')
|
||||
.text(item.desc, 60, y)
|
||||
.text(String(item.qty), 350, y, { width: 50, align: 'right' })
|
||||
.text(`${item.price.toFixed(2)} €`, 410, y, { width: 60, align: 'right' })
|
||||
.text(`${lineTotal.toFixed(2)} €`, 480, y, { width: 55, align: 'right' });
|
||||
y += 20;
|
||||
}
|
||||
|
||||
doc.moveTo(50, y + 5).lineTo(545, y + 5).strokeColor('#E5E7EB').stroke();
|
||||
y += 15;
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#1F4E79')
|
||||
.text('Total (Gross):', 380, y, { width: 90, align: 'right' })
|
||||
.text(`${total.toFixed(2)} €`, 480, y, { width: 55, align: 'right' });
|
||||
|
||||
// Output to VFS
|
||||
const filename = 'invoices/invoice-2026-089.pdf';
|
||||
await doc.toFile(filename);
|
||||
|
||||
return { success: true, file: filename, total };
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 8. Presenting Output to User
|
||||
|
||||
When you generate a PDF in `javascript_execute`, **always include a clickable Markdown link in your response summary**:
|
||||
- `[Download Invoice PDF](invoices/invoice-2026-089.pdf)`
|
||||
- `[Download Monthly Report](monthly-summary.pdf)`
|
||||
@@ -113,7 +113,7 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) {
|
||||
'YOUR TOOLS:',
|
||||
'1. dashback_request: HTTP requests (GET, POST, PUT, PATCH, DELETE) against QuixPOS Article Management System.',
|
||||
'2. ask_user: Prompt user for clarification or decisions. Pauses execution until response.',
|
||||
'3. javascript_execute: Sandboxed Node environment for computation, aggregation, and binary I/O. Assign module.exports. API: fetch("/path"). vfs.',
|
||||
'3. javascript_execute: Sandboxed Node environment for computation, aggregation, and binary I/O. Assign module.exports. API: fetch("/path"). vfs, sharp, ExcelJS.',
|
||||
'4. find_files: List VFS files. Set skills=true for SKILL files.',
|
||||
'5. read_file: Read VFS text file (seek/limit windowed).',
|
||||
'6. patch: Create, overwrite, or replace text in VFS files (only text write path; creates SKILL files).',
|
||||
@@ -131,7 +131,7 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) {
|
||||
'- Persistent per-company VFS. Paths are root-relative forward-slash (e.g. "labels/sheet1.png").',
|
||||
'- Can host an entire static website (HTML, CSS, JS, images, etc.).',
|
||||
'- Text: list with find_files, read with read_file, write with patch.',
|
||||
'- User attachments are saved to VFS (e.g. "attachments/...") and tagged in user messages as `[ATTACHMENT name= path= type=]`. Inspect attachments with appropriate tools: `read_file` (text/csv/json), `pdf_read` (PDFs), or `analyze_image` (images).',
|
||||
'- User attachments are saved to VFS (e.g. "attachments/...") and tagged in user messages as `[ATTACHMENT name= path= type=]`. Inspect attachments with appropriate tools: `read_file` (text/csv/json), `pdf_read` (PDFs), `analyze_image` (images), or `javascript_execute` with `ExcelJS` (Excel spreadsheets).',
|
||||
'- Markdown links: Internet links are "https://..." or simply "hostname.com" / "hostname.com/filename". A pure relative path with no dotted hostname is always a VFS relative path (e.g. `[download](report.csv)` or ``).',
|
||||
'',
|
||||
'SKILL FILES:',
|
||||
|
||||
@@ -6,6 +6,8 @@ import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import fsModule from 'fs';
|
||||
import { validateObjects, validateObject } from 'react-canvas-label-editor';
|
||||
import ExcelJS from 'exceljs';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { createVfs } from './vfs.js';
|
||||
|
||||
const { code, root, vmTimeout, bufferLimit, network, dashbackUrl, dashbackToken, vfsDir, googleAuthId } = workerData;
|
||||
@@ -198,6 +200,218 @@ function bridgeSharp(vfs) {
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
// ExcelJS reads and writes files via Node's `fs` when using `.readFile()` / `.writeFile()` / streaming.
|
||||
// Injecting the raw library would allow escaping VFS or reading/writing in the server process CWD.
|
||||
// We wrap `Workbook`, `stream.xlsx.WorkbookWriter`, and `stream.xlsx.WorkbookReader` so that file
|
||||
// paths are resolved through the VFS containment rules, parent directories are created on write,
|
||||
// and errors are properly sanitized without leaking host server paths.
|
||||
function bridgeExcelJS(vfs) {
|
||||
const sanitizePathError = (err, diskPath, virtualPath) => {
|
||||
if (!err) return err;
|
||||
if (typeof err.message === 'string' && diskPath) {
|
||||
err.message = err.message.split(diskPath).join(virtualPath);
|
||||
}
|
||||
if (typeof err.stack === 'string' && diskPath) {
|
||||
err.stack = err.stack.split(diskPath).join(virtualPath);
|
||||
}
|
||||
return err;
|
||||
};
|
||||
|
||||
class BridgedWorkbook extends ExcelJS.Workbook {
|
||||
get xlsx() {
|
||||
const x = super.xlsx;
|
||||
if (!x.__vfsBridged) {
|
||||
const origRead = x.readFile;
|
||||
const origWrite = x.writeFile;
|
||||
x.readFile = async function readFile(filename, options) {
|
||||
if (typeof filename !== 'string') {
|
||||
return origRead.call(this, filename, options);
|
||||
}
|
||||
const diskPath = vfs.resolveReadPath(filename);
|
||||
if (!fsModule.existsSync(diskPath)) {
|
||||
throw new Error(`File not found: ${filename}`);
|
||||
}
|
||||
try {
|
||||
return await origRead.call(this, diskPath, options);
|
||||
} catch (err) {
|
||||
throw sanitizePathError(err, diskPath, filename);
|
||||
}
|
||||
};
|
||||
x.writeFile = async function writeFile(filename, options) {
|
||||
if (typeof filename !== 'string') {
|
||||
return origWrite.call(this, filename, options);
|
||||
}
|
||||
const diskPath = vfs.resolveWritePath(filename);
|
||||
fsModule.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
try {
|
||||
return await origWrite.call(this, diskPath, options);
|
||||
} catch (err) {
|
||||
throw sanitizePathError(err, diskPath, filename);
|
||||
}
|
||||
};
|
||||
x.__vfsBridged = true;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
get csv() {
|
||||
const c = super.csv;
|
||||
if (!c.__vfsBridged) {
|
||||
const origRead = c.readFile;
|
||||
const origWrite = c.writeFile;
|
||||
c.readFile = async function readFile(filename, options) {
|
||||
if (typeof filename !== 'string') {
|
||||
return origRead.call(this, filename, options);
|
||||
}
|
||||
const diskPath = vfs.resolveReadPath(filename);
|
||||
if (!fsModule.existsSync(diskPath)) {
|
||||
throw new Error(`File not found: ${filename}`);
|
||||
}
|
||||
try {
|
||||
return await origRead.call(this, diskPath, options);
|
||||
} catch (err) {
|
||||
throw sanitizePathError(err, diskPath, filename);
|
||||
}
|
||||
};
|
||||
c.writeFile = async function writeFile(filename, options) {
|
||||
if (typeof filename !== 'string') {
|
||||
return origWrite.call(this, filename, options);
|
||||
}
|
||||
const diskPath = vfs.resolveWritePath(filename);
|
||||
fsModule.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
try {
|
||||
return await origWrite.call(this, diskPath, options);
|
||||
} catch (err) {
|
||||
throw sanitizePathError(err, diskPath, filename);
|
||||
}
|
||||
};
|
||||
c.__vfsBridged = true;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
addImage(image) {
|
||||
if (image && typeof image.filename === 'string') {
|
||||
const diskPath = vfs.resolveReadPath(image.filename);
|
||||
return super.addImage({ ...image, filename: diskPath });
|
||||
}
|
||||
return super.addImage(image);
|
||||
}
|
||||
}
|
||||
|
||||
class BridgedWorkbookWriter extends ExcelJS.stream.xlsx.WorkbookWriter {
|
||||
constructor(options = {}) {
|
||||
let opts = options;
|
||||
if (options && typeof options.filename === 'string') {
|
||||
const diskPath = vfs.resolveWritePath(options.filename);
|
||||
fsModule.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
opts = { ...options, filename: diskPath };
|
||||
}
|
||||
super(opts);
|
||||
}
|
||||
|
||||
addImage(image) {
|
||||
if (image && typeof image.filename === 'string') {
|
||||
const diskPath = vfs.resolveReadPath(image.filename);
|
||||
return super.addImage({ ...image, filename: diskPath });
|
||||
}
|
||||
return super.addImage(image);
|
||||
}
|
||||
}
|
||||
|
||||
class BridgedWorkbookReader extends ExcelJS.stream.xlsx.WorkbookReader {
|
||||
constructor(input, options) {
|
||||
let resolvedInput = input;
|
||||
if (typeof input === 'string') {
|
||||
const diskPath = vfs.resolveReadPath(input);
|
||||
if (!fsModule.existsSync(diskPath)) {
|
||||
throw new Error(`File not found: ${input}`);
|
||||
}
|
||||
resolvedInput = diskPath;
|
||||
}
|
||||
super(resolvedInput, options);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...ExcelJS,
|
||||
Workbook: BridgedWorkbook,
|
||||
stream: {
|
||||
...ExcelJS.stream,
|
||||
xlsx: {
|
||||
...ExcelJS.stream?.xlsx,
|
||||
WorkbookWriter: BridgedWorkbookWriter,
|
||||
WorkbookReader: BridgedWorkbookReader,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// PDFKit generates PDF documents. In Node, it emits binary chunks as a readable stream
|
||||
// or writes directly to `fs.createWriteStream`. It also loads images and custom fonts via `fs.readFileSync`.
|
||||
// We wrap `PDFDocument` so:
|
||||
// 1. `doc.image(src, ...)` and `doc.font(src, ...)` resolve files via VFS (supporting disk paths,
|
||||
// hashes, and relative paths) and pass raw Buffers so PDFKit avoids uncontained host disk reads.
|
||||
// 2. Provide `await doc.toFile(filename)` to directly stream and finalize to a VFS file.
|
||||
// 3. Provide `await doc.toBuffer()` to finalize and return the full PDF Buffer.
|
||||
// 4. Piping via `doc.pipe(fs.createWriteStream('out.pdf'))` continues to work seamlessly with VFS.
|
||||
function bridgePDFKit(vfs) {
|
||||
const STANDARD_FONTS = new Set([
|
||||
'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique',
|
||||
'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique',
|
||||
'Times-Roman', 'Times-Bold', 'Times-Italic', 'Times-BoldItalic',
|
||||
'Symbol', 'ZapfDingbats',
|
||||
]);
|
||||
|
||||
class BridgedPDFDocument extends PDFDocument {
|
||||
font(src, family, size) {
|
||||
if (typeof src === 'string' && !STANDARD_FONTS.has(src)) {
|
||||
try {
|
||||
const buf = vfs.readFileSync(src);
|
||||
return super.font(buf, family, size);
|
||||
} catch {
|
||||
return super.font(src, family, size);
|
||||
}
|
||||
}
|
||||
return super.font(src, family, size);
|
||||
}
|
||||
|
||||
image(src, ...args) {
|
||||
if (typeof src === 'string') {
|
||||
const buf = vfs.readFileSync(src);
|
||||
return super.image(buf, ...args);
|
||||
}
|
||||
return super.image(src, ...args);
|
||||
}
|
||||
|
||||
async toBuffer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
this.on('data', (chunk) => chunks.push(chunk));
|
||||
this.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
this.on('error', reject);
|
||||
this.end();
|
||||
});
|
||||
}
|
||||
|
||||
async toFile(virtualPath) {
|
||||
const diskPath = vfs.resolveWritePath(virtualPath);
|
||||
fsModule.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
const writeStream = fsModule.createWriteStream(diskPath);
|
||||
return new Promise((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
this.on('error', reject);
|
||||
this.pipe(writeStream);
|
||||
this.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return BridgedPDFDocument;
|
||||
}
|
||||
|
||||
const NETWORK_GLOBALS = {
|
||||
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
|
||||
// WebCrypto globals (bridged across the vm2 realm boundary — see bridgeCrypto above).
|
||||
@@ -329,6 +543,9 @@ void (async () => {
|
||||
try {
|
||||
const vfs = createVfs(vfsDir, { googleAuthId });
|
||||
|
||||
const bridgedExcel = bridgeExcelJS(vfs);
|
||||
const bridgedPDF = bridgePDFKit(vfs);
|
||||
|
||||
const vm = new NodeVM({
|
||||
console: 'off',
|
||||
sandbox: {
|
||||
@@ -336,6 +553,11 @@ try {
|
||||
fs: vfs,
|
||||
vfs,
|
||||
sharp: bridgeSharp(vfs),
|
||||
ExcelJS: bridgedExcel,
|
||||
exceljs: bridgedExcel,
|
||||
PDFDocument: bridgedPDF,
|
||||
PDFKit: bridgedPDF,
|
||||
pdfkit: bridgedPDF,
|
||||
validateLabelObjects: (objects) => validateObjects(JSON.parse(JSON.stringify(objects))),
|
||||
validateLabelObject: (object) => validateObject(JSON.parse(JSON.stringify(object))),
|
||||
...NETWORK_GLOBALS,
|
||||
|
||||
@@ -126,7 +126,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
|
||||
'REST API: `await fetch("/path")` (authenticated). ' +
|
||||
'VFS: directly use global `fs.readFileSync`/`writeFileSync` for binary artifacts/buffers/images (`image/` is read-only; paths must be relative paths, root is not allowed). VFS can host a static website (HTML, CSS, JS, images, etc.). ' +
|
||||
'Embed generated images/links in markdown: ``. Internet links use `https://...` or `hostname.com`/`hostname.com/filename`; a pure relative file with no dotted hostname is always a VFS relative path. Use find_files/read_file/patch for text. ' +
|
||||
'All APIs are pre-injected globals: `fs`/`vfs` (virtual filesystem), `sharp` (image processing), WebCrypto `crypto` (`subtle`, `randomUUID`), and standard web globals. Do NOT use `require()` or `import` (disabled; all necessary APIs are already in global scope).',
|
||||
'All APIs are pre-injected globals: `fs`/`vfs` (virtual filesystem), `sharp` (image processing), `ExcelJS`/`exceljs` (spreadsheet processing with VFS support for readFile/writeFile/writeBuffer/load), `PDFDocument`/`PDFKit`/`pdfkit` (PDF generation with VFS support for toFile/toBuffer/pipe), WebCrypto `crypto` (`subtle`, `randomUUID`), and standard web globals. Do NOT use `require()` or `import` (disabled; all necessary APIs are already in global scope).',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
|
||||
@@ -124,7 +124,9 @@ export function createReadFileTool({ googleAuthId }) {
|
||||
`"${clean}" is a binary file (${raw.length} bytes${ext ? `, ${ext}` : ''}) and cannot be read as text. ` +
|
||||
(['.png', '.jpg', '.jpeg', '.avif', '.webp', '.gif', '.bmp'].includes(ext)
|
||||
? 'Use analyze_image({ path: ' + JSON.stringify(clean) + ' }) to have a vision model inspect it.'
|
||||
: 'Fetch it as bytes from javascript_execute with fs.readFileSync if you need its contents.')
|
||||
: (['.xlsx', '.xls', '.xlsm'].includes(ext)
|
||||
? 'Use javascript_execute with ExcelJS to inspect or modify spreadsheet files.'
|
||||
: 'Fetch it as bytes from javascript_execute with fs.readFileSync if you need its contents.'))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
108
src/tools/vfs.js
108
src/tools/vfs.js
@@ -32,6 +32,10 @@ export function getMimeType(filePath) {
|
||||
case '.log': return 'text/plain; charset=utf-8';
|
||||
case '.csv': return 'text/csv; charset=utf-8';
|
||||
case '.tsv': return 'text/tab-separated-values; charset=utf-8';
|
||||
case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
case '.xls': return 'application/vnd.ms-excel';
|
||||
case '.xlsm': return 'application/vnd.ms-excel.sheet.macroEnabled.12';
|
||||
case '.xltx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.template';
|
||||
case '.md':
|
||||
case '.skill': return 'text/markdown; charset=utf-8';
|
||||
case '.xml': return 'application/xml; charset=utf-8';
|
||||
@@ -68,7 +72,7 @@ export function extractFullHash(p) {
|
||||
|
||||
export function isReadOnlyDatabasePath(filePath) {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) return false;
|
||||
const clean = filePath.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
const clean = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
const lower = clean.toLowerCase();
|
||||
if (lower === 'image' || lower.startsWith('image/')) return true;
|
||||
if (extractFullHash(clean)) return true;
|
||||
@@ -186,6 +190,23 @@ export async function prepareVfs({ googleAuthId }) {
|
||||
|
||||
try {
|
||||
const files = await store.modelvfs.list(googleAuthId);
|
||||
const existingNames = new Set(files.map((f) => f.name));
|
||||
const defaultSkills = getDefaultSkills();
|
||||
for (const skill of defaultSkills) {
|
||||
if (!existingNames.has(skill.name)) {
|
||||
try {
|
||||
await store.modelvfs.write(googleAuthId, {
|
||||
name: skill.name,
|
||||
mime: 'text/markdown',
|
||||
content: Buffer.from(skill.content, 'utf8'),
|
||||
});
|
||||
files.push({ name: skill.name });
|
||||
existingNames.add(skill.name);
|
||||
} catch (err) {
|
||||
console.warn(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
const diskPath = path.resolve(vfsRoot, file.name);
|
||||
if (!diskPath.startsWith(vfsRoot)) continue;
|
||||
@@ -276,10 +297,10 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
};
|
||||
|
||||
const safeResolve = (p) => {
|
||||
if (typeof p !== 'string' || !p.trim()) {
|
||||
throw new Error('Path must be a non-empty string');
|
||||
if (typeof p !== 'string' || !p.trim()) {
|
||||
throw new Error('Path must be a non-empty string');
|
||||
}
|
||||
const cleanVirtual = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
|
||||
const cleanVirtual = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
|
||||
const resolved = path.resolve(vfsRoot, cleanVirtual);
|
||||
const relative = path.relative(vfsRoot, resolved);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
@@ -302,20 +323,44 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
|
||||
const vfs = {
|
||||
readFileSync: (p, opt) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
if (buf) {
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? buf.toString(encoding) : buf;
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
if (buf) {
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? buf.toString(encoding) : buf;
|
||||
}
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
return fs.readFileSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
readFile: (p, opt, cb) => {
|
||||
const callback = typeof opt === 'function' ? opt : cb;
|
||||
const options = typeof opt === 'function' ? undefined : opt;
|
||||
try {
|
||||
const data = vfs.readFileSync(p, options);
|
||||
if (typeof callback === 'function') process.nextTick(callback, null, data);
|
||||
} catch (err) {
|
||||
if (typeof callback === 'function') process.nextTick(callback, err);
|
||||
else throw err;
|
||||
}
|
||||
},
|
||||
|
||||
createReadStream: (p, opt) => {
|
||||
const diskPath = safeResolve(p);
|
||||
if (!fs.existsSync(diskPath)) {
|
||||
const err = new Error(`ENOENT: no such file or directory, open '${String(p)}'`);
|
||||
err.code = 'ENOENT';
|
||||
err.path = String(p);
|
||||
err.syscall = 'open';
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
return fs.createReadStream(diskPath, opt);
|
||||
},
|
||||
|
||||
writeFileSync: (p, data, opt) => {
|
||||
@@ -329,6 +374,25 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
writeFile: (p, data, opt, cb) => {
|
||||
const callback = typeof opt === 'function' ? opt : cb;
|
||||
const options = typeof opt === 'function' ? undefined : opt;
|
||||
try {
|
||||
vfs.writeFileSync(p, data, options);
|
||||
if (typeof callback === 'function') process.nextTick(callback, null);
|
||||
} catch (err) {
|
||||
if (typeof callback === 'function') process.nextTick(callback, err);
|
||||
else throw err;
|
||||
}
|
||||
},
|
||||
|
||||
createWriteStream: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
return fs.createWriteStream(diskPath, opt);
|
||||
},
|
||||
|
||||
appendFileSync: (p, data, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
@@ -436,6 +500,18 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
return safeResolve(p);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a virtual path to an absolute on-disk path for reading, applying containment checks.
|
||||
*/
|
||||
resolveReadPath: (p) => {
|
||||
return safeResolve(p);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sanitize on-disk error paths back to virtual paths for display in sandbox transcripts.
|
||||
*/
|
||||
sanitizeError: (err, virtualPath) => sanitizeFsError(err, virtualPath),
|
||||
};
|
||||
|
||||
return vfs;
|
||||
|
||||
Reference in New Issue
Block a user