File creation agent skills (#5280)

* Powerpoint File Creation (#5278)

* wip

* download card

* UI for downloading

* move to fs system with endpoint to pull files

* refactor UI

* final-pass

* remove save-file-browser skill and refactor

* remove fileDownload event

* reset

* reset file

* reset timeout

* persist toggle

* Txt creation (#5279)

* wip

* download card

* UI for downloading

* move to fs system with endpoint to pull files

* refactor UI

* final-pass

* remove save-file-browser skill and refactor

* remove fileDownload event

* reset

* reset file

* reset timeout

* wip

* persist toggle

* add arbitrary text creation file

* Add PDF document generation with markdown formatting (#5283)

add support for branding in bottom right corner
refactor core utils and frontend rendering

* Xlsx document creation (#5284)

add Excel doc & sheet creation

* Basic docx creation (#5285)

* Basic docx creation

* add test theme support + styling and title pages

* simplify skill selection

* handle TG attachments

* send documents over tg

* lazy import

* pin deps

* fix lock

* i18n for file creation (#5286)

i18n for file-creation
connect #5280

* theme overhaul

* Add PPTX subagent for better results

* forgot files

* Add PPTX subagent for better results (#5287)

* Add PPTX subagent for better results

* forgot files

* make sub-agent use proper tool calling if it can and better UI hints
This commit is contained in:
Timothy Carambat
2026-03-30 15:13:39 -07:00
committed by GitHub
parent 0bfd27c6df
commit 7aaea7f514
70 changed files with 7349 additions and 932 deletions

View File

@@ -0,0 +1,301 @@
const {
getDeploymentVersion,
} = require("../../../../../../endpoints/utils.js");
const createFilesLib = require("../lib.js");
const {
getTheme,
getMargins,
loadLibraries,
htmlToDocxElements,
createCoverPageSection,
createRunningHeader,
createRunningFooter,
DEFAULT_NUMBERING_CONFIG,
} = require("./utils.js");
module.exports.CreateDocxFile = {
name: "create-docx-file",
plugin: function () {
return {
name: "create-docx-file",
setup(aibitat) {
aibitat.function({
super: aibitat,
name: this.name,
description:
"Create a Microsoft Word document (.docx) from markdown or plain text content. Supports professional styling with color themes, title pages, and running headers/footers.",
examples: [
{
prompt: "Create a Word document with meeting notes",
call: JSON.stringify({
filename: "meeting-notes.docx",
content:
"# Meeting Notes - Q1 Planning\n\n## Attendees\n- John Smith\n- Sarah Johnson\n- Mike Chen\n\n## Agenda\n1. Review Q4 results\n2. Set Q1 goals\n3. Assign tasks\n\n## Action Items\n| Person | Task | Due Date |\n|--------|------|----------|\n| John | Prepare budget report | Jan 15 |\n| Sarah | Draft marketing plan | Jan 20 |\n| Mike | Schedule follow-up | Jan 10 |",
}),
},
{
prompt:
"Create a professional project proposal with a title page",
call: JSON.stringify({
filename: "project-proposal.docx",
title: "Project Alpha Proposal",
subtitle: "Strategic Initiative for Q2 2024",
author: "Product Team",
theme: "blue",
includeTitlePage: true,
content:
"## Executive Summary\nThis proposal outlines the development of **Project Alpha**, a next-generation platform.\n\n## Objectives\n- Increase efficiency by 40%\n- Reduce costs by $50,000 annually\n- Improve user satisfaction\n\n## Timeline\n| Phase | Duration | Deliverables |\n|-------|----------|-------------|\n| Phase 1 | 4 weeks | Requirements |\n| Phase 2 | 8 weeks | Development |\n| Phase 3 | 2 weeks | Testing |\n\n## Budget\nTotal estimated budget: **$150,000**",
}),
},
{
prompt: "Create technical documentation with warm theme",
call: JSON.stringify({
filename: "api-documentation.docx",
title: "API Documentation",
theme: "warm",
margins: "narrow",
content:
"# API Documentation\n\n## Authentication\nAll API requests require a Bearer token in the Authorization header.\n\n```javascript\nconst headers = {\n 'Authorization': 'Bearer YOUR_TOKEN',\n 'Content-Type': 'application/json'\n};\n```\n\n## Endpoints\n\n### GET /users\nReturns a list of all users.\n\n### POST /users\nCreates a new user.\n\n> **Note:** Rate limiting applies to all endpoints.",
}),
},
],
parameters: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
filename: {
type: "string",
description:
"The filename for the Word document. Will automatically add .docx extension if not present.",
},
title: {
type: "string",
description:
"Document title for metadata and title page. If not provided, will be extracted from content or use filename.",
},
subtitle: {
type: "string",
description:
"Optional subtitle displayed on the title page below the main title.",
},
author: {
type: "string",
description:
"Optional author name displayed on the title page.",
},
content: {
type: "string",
description:
"The content to convert to a Word document. Fully supports markdown formatting.",
},
theme: {
type: "string",
enum: ["neutral", "blue", "warm"],
description:
"Color theme for the document. 'neutral' (slate/grey), 'blue' (corporate blue), or 'warm' (earthy tones). Defaults to neutral.",
},
margins: {
type: "string",
enum: ["normal", "narrow", "wide"],
description:
"Page margin preset. 'normal' (standard), 'narrow' (data-heavy docs), or 'wide' (letters/memos). Defaults to normal.",
},
includeTitlePage: {
type: "boolean",
description:
"Include a professional title page with centered title, subtitle, author, and date. Content starts on page 2 with running headers/footers.",
},
},
required: ["filename", "content"],
additionalProperties: false,
},
handler: async function ({
filename = "document.docx",
title = null,
subtitle = null,
author = null,
content = "",
theme = "neutral",
margins = "normal",
includeTitlePage = false,
}) {
try {
this.super.handlerProps.log(`Using the create-docx-file tool.`);
const hasExtension = /\.docx$/i.test(filename);
if (!hasExtension) filename = `${filename}.docx`;
const displayFilename = filename.split("/").pop();
const documentTitle =
title ||
content.match(/^#\s+(.+)$/m)?.[1] ||
displayFilename.replace(/\.docx$/i, "");
if (this.super.requestToolApproval) {
const approval = await this.super.requestToolApproval({
skillName: this.name,
payload: { filename: displayFilename, title: documentTitle },
description: `Create Word document "${displayFilename}"`,
});
if (!approval.approved) {
this.super.introspect(
`${this.caller}: User rejected the ${this.name} request.`
);
return approval.message;
}
}
this.super.introspect(
`${this.caller}: Creating Word document "${displayFilename}"${includeTitlePage ? " with title page" : ""}`
);
const libs = await loadLibraries();
const { marked, docx } = libs;
const { Document, Packer, Paragraph, TextRun } = docx;
marked.setOptions({
gfm: true,
breaks: true,
});
const themeColors = getTheme(theme);
const marginConfig = getMargins(margins);
const html = marked.parse(content);
this.super.handlerProps.log(
`create-docx-file: Parsed markdown to HTML (${html.length} chars), theme: ${theme}, margins: ${margins}`
);
const logoBuffer = createFilesLib.getLogo({
forDarkBackground: false,
format: "buffer",
});
const docElements = await htmlToDocxElements(
html,
libs,
this.super.handlerProps.log,
themeColors
);
if (docElements.length === 0) {
docElements.push(
new Paragraph({
children: [new TextRun({ text: content })],
})
);
}
const sections = [];
if (includeTitlePage) {
const currentDate = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
sections.push(
createCoverPageSection(docx, {
title: documentTitle,
subtitle,
author,
date: currentDate,
theme: themeColors,
margins: marginConfig,
logoBuffer,
})
);
sections.push({
properties: {
page: {
margin: marginConfig,
},
},
children: docElements,
headers: {
default: createRunningHeader(
docx,
documentTitle,
themeColors
),
},
footers: {
default: createRunningFooter(docx, logoBuffer, themeColors),
},
});
} else {
sections.push({
properties: {
page: {
margin: marginConfig,
},
},
children: docElements,
footers: {
default: createRunningFooter(docx, logoBuffer, themeColors),
},
});
}
const doc = new Document({
title: documentTitle,
creator: `AnythingLLM ${getDeploymentVersion()}`,
description: `Word Document generated by AnythingLLM ${getDeploymentVersion()}`,
numbering: DEFAULT_NUMBERING_CONFIG,
sections,
});
const buffer = await Packer.toBuffer(doc);
const bufferSizeKB = (buffer.length / 1024).toFixed(2);
this.super.handlerProps.log(
`create-docx-file: Generated buffer - size: ${bufferSizeKB}KB, title: "${documentTitle}", theme: ${theme}`
);
const savedFile = await createFilesLib.saveGeneratedFile({
fileType: "docx",
extension: "docx",
buffer,
displayFilename,
});
this.super.socket.send("fileDownloadCard", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
createFilesLib.registerOutput(this.super, "DocxFileDownload", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
this.super.introspect(
`${this.caller}: Successfully created Word document "${displayFilename}"`
);
const styleInfo = [
theme !== "neutral" ? `${theme} theme` : null,
margins !== "normal" ? `${margins} margins` : null,
includeTitlePage ? "title page" : null,
].filter(Boolean);
const styleDesc =
styleInfo.length > 0 ? ` with ${styleInfo.join(", ")}` : "";
return `Successfully created Word document "${displayFilename}" (${bufferSizeKB}KB)${styleDesc}. The document includes formatted content with tables, images, Page X of Y footer, and professional styling.`;
} catch (e) {
this.super.handlerProps.log(
`create-docx-file error: ${e.message}`
);
this.super.introspect(`Error: ${e.message}`);
return `Error creating Word document: ${e.message}`;
}
},
});
},
};
},
};

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env node
/**
* Test utility to generate sample Word documents for all themes and configurations.
* Run from the server directory: node utils/agents/aibitat/plugins/create-files/docx/test-themes.js
*
* Output goes to: storage/generated-files/docx-theme-previews/
*/
const path = require("path");
const fs = require("fs/promises");
const {
DOCUMENT_STYLES,
getTheme,
getMargins,
loadLibraries,
htmlToDocxElements,
createCoverPageSection,
createRunningHeader,
createRunningFooter,
DEFAULT_NUMBERING_CONFIG,
} = require("./utils.js");
const OUTPUT_DIR = path.resolve(
__dirname,
"../../../../../../storage/generated-files/docx-theme-previews"
);
const SAMPLE_CONTENT = `# Sample Document
## Executive Summary
This document demonstrates the **styling capabilities** of the Word document generator. It includes various content types to showcase how themes affect the visual appearance.
## Key Features
- Professional title pages with centered content
- Running headers with document title
- Page X of Y footer numbering
- Color-coordinated themes throughout
## Data Overview
| Metric | Q1 | Q2 | Q3 | Q4 |
|--------|-----|-----|-----|-----|
| Revenue | $1.2M | $1.5M | $1.8M | $2.1M |
| Growth | +15% | +25% | +20% | +17% |
| Users | 10K | 15K | 22K | 30K |
## Technical Details
Here is an example code block:
\`\`\`javascript
const config = {
theme: "blue",
margins: "normal",
includeTitlePage: true
};
\`\`\`
> **Note:** This blockquote demonstrates how accent colors are applied to the left border. Blockquotes are useful for callouts and important notes.
## Conclusion
The themed document system provides a consistent, professional look across all generated documents. Each theme cascades colors through:
1. Heading text colors
2. Table header backgrounds
3. Blockquote borders
4. Footer text styling
---
Thank you for reviewing this sample document.
`;
const MINIMAL_CONTENT = `# Quick Report
## Summary
A brief document to test minimal content rendering.
- Point one
- Point two
- Point three
| Item | Value |
|------|-------|
| A | 100 |
| B | 200 |
`;
async function generateThemePreview(themeName, themeConfig, options = {}) {
const libs = await loadLibraries();
const { marked, docx } = libs;
const { Document, Packer, Paragraph, TextRun } = docx;
marked.setOptions({ gfm: true, breaks: true });
const {
margins = "normal",
includeTitlePage = false,
content = SAMPLE_CONTENT,
subtitle = null,
author = null,
} = options;
const marginConfig = getMargins(margins);
const title = `${themeConfig.name || themeName} Theme Preview`;
const html = marked.parse(content);
const docElements = await htmlToDocxElements(
html,
libs,
console.log,
themeConfig
);
if (docElements.length === 0) {
docElements.push(
new Paragraph({
children: [new TextRun({ text: content })],
})
);
}
const sections = [];
if (includeTitlePage) {
const currentDate = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
sections.push(
createCoverPageSection(docx, {
title,
subtitle: subtitle || `Demonstrating the ${themeName} color scheme`,
author: author || "AnythingLLM Theme Tester",
date: currentDate,
theme: themeConfig,
margins: marginConfig,
logoBuffer: null,
})
);
sections.push({
properties: {
page: { margin: marginConfig },
titlePage: true,
},
children: docElements,
headers: {
default: createRunningHeader(docx, title, themeConfig),
},
footers: {
default: createRunningFooter(docx, null, themeConfig),
},
});
} else {
sections.push({
properties: {
page: { margin: marginConfig },
},
children: docElements,
footers: {
default: createRunningFooter(docx, null, themeConfig),
},
});
}
const doc = new Document({
title,
creator: "AnythingLLM Theme Tester",
description: `Theme preview for ${themeName}`,
numbering: DEFAULT_NUMBERING_CONFIG,
sections,
});
return Packer.toBuffer(doc);
}
async function main() {
console.log("DOCX Theme Preview Generator");
console.log("============================\n");
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const themes = Object.keys(DOCUMENT_STYLES.themes);
const marginPresets = Object.keys(DOCUMENT_STYLES.margins);
console.log(`Themes: ${themes.join(", ")}`);
console.log(`Margins: ${marginPresets.join(", ")}\n`);
const configs = [];
for (const themeName of themes) {
configs.push({
name: `theme-${themeName}-simple`,
theme: themeName,
margins: "normal",
includeTitlePage: false,
content: SAMPLE_CONTENT,
});
configs.push({
name: `theme-${themeName}-with-title-page`,
theme: themeName,
margins: "normal",
includeTitlePage: true,
content: SAMPLE_CONTENT,
});
}
for (const marginName of marginPresets) {
configs.push({
name: `margins-${marginName}`,
theme: "neutral",
margins: marginName,
includeTitlePage: true,
content: SAMPLE_CONTENT,
});
}
configs.push({
name: `full-featured-blue`,
theme: "blue",
margins: "normal",
includeTitlePage: true,
content: SAMPLE_CONTENT,
subtitle: "A Complete Feature Demonstration",
author: "Documentation Team",
});
configs.push({
name: `minimal-warm`,
theme: "warm",
margins: "narrow",
includeTitlePage: false,
content: MINIMAL_CONTENT,
});
console.log(`Generating ${configs.length} preview documents...\n`);
for (const config of configs) {
const themeConfig = getTheme(config.theme);
try {
const buffer = await generateThemePreview(config.theme, themeConfig, {
margins: config.margins,
includeTitlePage: config.includeTitlePage,
content: config.content,
subtitle: config.subtitle,
author: config.author,
});
const filename = `${config.name}.docx`;
const filepath = path.join(OUTPUT_DIR, filename);
await fs.writeFile(filepath, buffer);
const sizeKB = (buffer.length / 1024).toFixed(1);
const titlePage = config.includeTitlePage ? "✓ title" : " - ";
console.log(
`${config.name.padEnd(30)} [${config.theme.padEnd(7)}] [${config.margins.padEnd(6)}] ${titlePage} (${sizeKB}KB)`
);
} catch (error) {
console.error(`${config.name.padEnd(30)} → Error: ${error.message}`);
console.error(error.stack);
}
}
console.log(`\n✅ Done! Files saved to: ${OUTPUT_DIR}`);
console.log(
"\nOpen the .docx files in Microsoft Word or LibreOffice to preview each configuration."
);
console.log("\n--- Theme Color Reference ---");
for (const [name, colors] of Object.entries(DOCUMENT_STYLES.themes)) {
console.log(`\n${name.toUpperCase()}:`);
console.log(` Heading: #${colors.heading}`);
console.log(` Accent: #${colors.accent}`);
console.log(` Table Header: #${colors.tableHeader}`);
console.log(` Border: #${colors.border}`);
console.log(` Cover BG: #${colors.coverBg}`);
console.log(` Footer Text: #${colors.footerText}`);
}
console.log("\n--- Margin Presets (twips) ---");
for (const [name, margins] of Object.entries(DOCUMENT_STYLES.margins)) {
const inchTop = (margins.top / 1440).toFixed(2);
const inchLeft = (margins.left / 1440).toFixed(2);
console.log(
`${name.padEnd(8)}: top/bottom=${inchTop}" left/right=${inchLeft}"`
);
}
}
main().catch(console.error);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
const { CreatePptxPresentation } = require("./pptx/create-presentation.js");
const { CreateTextFile } = require("./text/create-text-file.js");
const { CreatePdfFile } = require("./pdf/create-pdf-file.js");
const { CreateExcelFile } = require("./xlsx/create-excel-file.js");
const { CreateDocxFile } = require("./docx/create-docx-file.js");
const createFilesAgent = {
name: "create-files-agent",
startupConfig: {
params: {},
},
plugin: [
CreatePptxPresentation,
CreateTextFile,
CreatePdfFile,
CreateExcelFile,
CreateDocxFile,
],
};
module.exports = {
createFilesAgent,
};

View File

@@ -0,0 +1,298 @@
const path = require("path");
const fs = require("fs/promises");
const fsSync = require("fs");
const { v4: uuidv4 } = require("uuid");
/**
* Manages file creation operations for binary document formats.
* Handles both browser download and filesystem write modes.
* All generated files are saved to storage/generated-files directory.
*/
class CreateFilesManager {
#outputDirectory = null;
#isInitialized = false;
/**
* Gets the output directory for generated files.
* @returns {string} The output directory path (storage/generated-files)
*/
#getOutputDirectory() {
const storageRoot =
process.env.STORAGE_DIR ||
path.resolve(__dirname, "../../../../../storage");
return path.join(storageRoot, "generated-files");
}
/**
* Initializes the create-files manager and ensures output directory exists.
* @returns {Promise<string>} The output directory path
*/
async #initialize() {
this.#outputDirectory = this.#getOutputDirectory();
try {
await fs.mkdir(this.#outputDirectory, { recursive: true });
} catch (error) {
console.error(
`Warning: Could not create output directory ${this.#outputDirectory}: ${error.message}`
);
}
this.#isInitialized = true;
return this.#outputDirectory;
}
/**
* Ensures the create-files manager is initialized before use.
* @returns {Promise<void>}
*/
async ensureInitialized() {
if (!this.#isInitialized) await this.#initialize();
}
/**
* Checks if file creation tools are available.
* @returns {boolean} True if tools are available
*/
isToolAvailable() {
if (process.env.NODE_ENV === "development") return true;
return process.env.ANYTHING_LLM_RUNTIME === "docker";
}
/**
* Gets the output directory path.
* @returns {Promise<string>} The output directory path
*/
async getOutputDirectory() {
await this.ensureInitialized();
return this.#outputDirectory;
}
/**
* Writes binary content (Buffer) to a file.
* @param {string} filePath - Validated absolute path to write to
* @param {Buffer} buffer - Binary content to write
* @returns {Promise<void>}
*/
async writeBinaryFile(filePath, buffer) {
const parentDir = path.dirname(filePath);
const fileSizeBytes = buffer.length;
const fileSizeKB = (fileSizeBytes / 1024).toFixed(2);
const fileSizeMB = (fileSizeBytes / (1024 * 1024)).toFixed(2);
console.log(
`[CreateFilesManager] writeBinaryFile starting - path: ${filePath}, size: ${fileSizeKB}KB (${fileSizeMB}MB)`
);
await fs.mkdir(parentDir, { recursive: true });
await fs.writeFile(filePath, buffer);
console.log(
`[CreateFilesManager] writeBinaryFile completed - file saved to: ${filePath}`
);
}
/**
* Gets the MIME type for a file extension.
* @param {string} extension - File extension (with or without dot)
* @returns {string} MIME type
*/
getMimeType(extension) {
const ext = extension.startsWith(".") ? extension : `.${extension}`;
const mimeTypes = {
".pptx":
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
".xlsx":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".docx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pdf": "application/pdf",
".txt": "text/plain",
".csv": "text/csv",
".json": "application/json",
".html": "text/html",
".xml": "application/xml",
".zip": "application/zip",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".webm": "video/webm",
};
return mimeTypes[ext.toLowerCase()] || "application/octet-stream";
}
/**
* Checks if a file exists.
* @param {string} filePath - Path to check
* @returns {Promise<boolean>} True if file exists
*/
async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Reads a file as a Buffer.
* @param {string} filePath - Path to the file
* @returns {Promise<Buffer>} File content as Buffer
*/
async readBinaryFile(filePath) {
return await fs.readFile(filePath);
}
/**
* Registers an output to be persisted in the chat history.
* This allows files and other outputs to be re-rendered when viewing historical messages.
* @param {object} aibitat - The aibitat instance to register the output on
* @param {string} type - The type of output (e.g., "PptxFileDownload")
* @param {object} payload - The output payload data
*/
registerOutput(aibitat, type, payload) {
if (!aibitat) {
console.warn(
"[CreateFilesManager] Cannot register output - aibitat instance not provided"
);
return;
}
if (!aibitat._pendingOutputs) {
aibitat._pendingOutputs = [];
}
aibitat._pendingOutputs.push({ type, payload });
console.log(
`[CreateFilesManager] Registered output: type=${type}, total pending=${aibitat._pendingOutputs.length}`
);
}
/**
* Generates a standardized filename for generated files.
* Format: {fileType}-{fileUUID}.{extension}
* @param {string} fileType - Type identifier (e.g., 'pptx', 'xlsx')
* @param {string} extension - File extension (without dot)
* @returns {string} The generated filename
*/
generateFilename(fileType, extension) {
const fileUUID = uuidv4();
return `${fileType}-${fileUUID}.${extension}`;
}
/**
* Parses a generated filename to extract its components.
* @param {string} filename - The filename to parse
* @returns {{fileType: string, fileUUID: string, extension: string} | null}
*/
parseFilename(filename) {
const match = filename.match(/^([a-z]+)-([a-f0-9-]{36})\.(\w+)$/i);
if (!match) return null;
return {
fileType: match[1],
fileUUID: match[2],
extension: match[3],
};
}
/**
* Saves a generated file to storage and returns metadata for WebSocket/DB storage.
* This is the primary method for persisting agent-generated files.
* @param {object} params
* @param {string} params.fileType - Type identifier (e.g., 'pptx', 'xlsx')
* @param {string} params.extension - File extension (without dot)
* @param {Buffer} params.buffer - The file content as a Buffer
* @param {string} params.displayFilename - The user-friendly filename for display
* @returns {Promise<{filename: string, displayFilename: string, fileSize: number, storagePath: string}>}
*/
async saveGeneratedFile({ fileType, extension, buffer, displayFilename }) {
await this.ensureInitialized();
const filename = this.generateFilename(fileType, extension);
const storagePath = path.join(this.#outputDirectory, filename);
await this.writeBinaryFile(storagePath, buffer);
console.log(
`[CreateFilesManager] saveGeneratedFile - saved ${filename} (${(buffer.length / 1024).toFixed(2)}KB)`
);
return {
filename,
displayFilename,
fileSize: buffer.length,
storagePath,
};
}
/**
* Retrieves a generated file by its storage filename.
* @param {string} filename - The storage filename (must match {fileType}-{uuid}.{ext} format)
* @returns {Promise<{buffer: Buffer, storagePath: string} | null>}
*/
async getGeneratedFile(filename) {
await this.ensureInitialized();
// Defense-in-depth: validate filename format to prevent path traversal
if (!this.parseFilename(filename)) {
console.warn(
`[CreateFilesManager] getGeneratedFile - rejected invalid filename format: ${filename}`
);
return null;
}
const storagePath = path.join(this.#outputDirectory, filename);
const exists = await this.fileExists(storagePath);
if (!exists) return null;
const buffer = await this.readBinaryFile(storagePath);
return { buffer, storagePath };
}
/**
* Sanitizes a filename for use in Content-Disposition header to prevent header injection.
* Removes/replaces characters that could be used for header manipulation.
* @param {string} filename - The filename to sanitize
* @returns {string} Sanitized filename safe for Content-Disposition header
*/
sanitizeFilenameForHeader(filename) {
if (!filename || typeof filename !== "string") return "download";
return filename
.replace(/[\r\n"\\]/g, "_")
.replace(/[^\x20-\x7E]/g, "_")
.substring(0, 255);
}
/**
* Gets the AnythingLLM logo for branding.
* @param {Object} options
* @param {boolean} [options.forDarkBackground=false] - True to get light logo (for dark backgrounds), false for dark logo (for light backgrounds)
* @param {"buffer"|"dataUri"} [options.format="buffer"] - Return format: "buffer" for raw Buffer, "dataUri" for base64 data URI
* @returns {Buffer|string|null} Logo as Buffer, data URI string, or null if file not found
*/
getLogo({ forDarkBackground = false, format = "buffer" } = {}) {
const assetsPath = path.join(__dirname, "../../../../../storage/assets");
const filename = forDarkBackground
? "anything-llm.png"
: "anything-llm-invert.png";
try {
if (format === "dataUri") {
const base64 = fsSync.readFileSync(
path.join(assetsPath, filename),
"base64"
);
return `image/png;base64,${base64}`;
}
return fsSync.readFileSync(path.join(assetsPath, filename));
} catch {
return null;
}
}
}
module.exports = new CreateFilesManager();

View File

@@ -0,0 +1,138 @@
const createFilesLib = require("../lib.js");
const { applyBranding } = require("./utils.js");
module.exports.CreatePdfFile = {
name: "create-pdf-file",
plugin: function () {
return {
name: "create-pdf-file",
setup(aibitat) {
aibitat.function({
super: aibitat,
name: this.name,
description:
"Create a PDF document from markdown or plain text content. " +
"The content will be styled and converted to a professional PDF document. " +
"Supports markdown formatting including headers, lists, code blocks, tables, and more.",
examples: [
{
prompt: "Create a PDF report about quarterly sales",
call: JSON.stringify({
filename: "quarterly-sales-report.pdf",
content:
"# Quarterly Sales Report\n\n## Q1 2024 Summary\n\n### Key Metrics\n- Total Revenue: $1.2M\n- Growth: 15% YoY\n- New Customers: 234\n\n### Top Products\n1. Product A - $400K\n2. Product B - $350K\n3. Product C - $250K\n\n## Recommendations\n\nBased on the analysis, we recommend focusing on...",
}),
},
{
prompt: "Create a PDF document with meeting minutes",
call: JSON.stringify({
filename: "meeting-minutes.pdf",
content:
"# Team Meeting Minutes\n\n**Date:** January 15, 2024\n**Attendees:** John, Sarah, Mike, Lisa\n\n## Agenda Items\n\n### 1. Project Status Update\nThe project is on track for Q2 delivery. Key milestones:\n- [ ] Phase 1 complete\n- [x] Phase 2 in progress\n- [ ] Phase 3 pending\n\n### 2. Budget Review\n| Category | Allocated | Spent |\n|----------|-----------|-------|\n| Development | $50,000 | $35,000 |\n| Marketing | $20,000 | $12,000 |\n\n### Action Items\n- John: Complete technical review by Friday\n- Sarah: Schedule stakeholder meeting",
}),
},
{
prompt: "Create a PDF with code documentation",
call: JSON.stringify({
filename: "api-documentation.pdf",
content:
"# API Documentation\n\n## Authentication\n\nAll API requests require a Bearer token:\n\n```javascript\nfetch('/api/data', {\n headers: {\n 'Authorization': 'Bearer YOUR_TOKEN'\n }\n});\n```\n\n## Endpoints\n\n### GET /api/users\n\nReturns a list of all users.\n\n**Response:**\n```json\n{\n \"users\": [...],\n \"total\": 100\n}\n```",
}),
},
],
parameters: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
filename: {
type: "string",
description:
"The filename for the PDF document. The .pdf extension will be added automatically if not provided.",
},
content: {
type: "string",
description:
"The markdown or plain text content to convert to PDF. Supports full markdown syntax including headers (#, ##, ###), bold (**text**), italic (*text*), lists, code blocks, tables, and more.",
},
},
required: ["filename", "content"],
additionalProperties: false,
},
handler: async function ({
filename = "document.pdf",
content = "",
}) {
try {
this.super.handlerProps.log(`Using the create-pdf-file tool.`);
const hasExtension = /\.pdf$/i.test(filename);
if (!hasExtension) filename = `${filename}.pdf`;
if (this.super.requestToolApproval) {
const approval = await this.super.requestToolApproval({
skillName: this.name,
payload: { filename },
description: `Create PDF document "${filename}"`,
});
if (!approval.approved) {
this.super.introspect(
`${this.caller}: User rejected the ${this.name} request.`
);
return approval.message;
}
}
this.super.introspect(
`${this.caller}: Creating PDF document "${filename}"`
);
const { markdownToPdf } = await import("@mdpdf/mdpdf");
const { PDFDocument, rgb, StandardFonts } = await import(
"pdf-lib"
);
const rawBuffer = await markdownToPdf(content);
const pdfDoc = await PDFDocument.load(rawBuffer);
await applyBranding(pdfDoc, { rgb, StandardFonts });
const buffer = await pdfDoc.save();
const bufferSizeKB = (buffer.length / 1024).toFixed(2);
const displayFilename = filename.split("/").pop();
const savedFile = await createFilesLib.saveGeneratedFile({
fileType: "pdf",
extension: "pdf",
buffer,
displayFilename,
});
this.super.socket.send("fileDownloadCard", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
createFilesLib.registerOutput(this.super, "PdfFileDownload", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
this.super.introspect(
`${this.caller}: Successfully created PDF document "${displayFilename}"`
);
return `Successfully created PDF document "${displayFilename}" (${bufferSizeKB}KB).`;
} catch (e) {
this.super.handlerProps.log(
`create-pdf-file error: ${e.message}`
);
this.super.introspect(`Error: ${e.message}`);
return `Error creating PDF document: ${e.message}`;
}
},
});
},
};
},
};

View File

@@ -0,0 +1,70 @@
const createFilesLib = require("../lib.js");
/**
* Applies AnythingLLM branding to a PDF document.
* Adds a logo watermark or fallback text to the bottom-right of each page.
* @param {PDFDocument} pdfDoc - The pdf-lib PDFDocument instance
* @param {Object} pdfLib - The pdf-lib module exports (rgb, StandardFonts)
* @returns {Promise<void>}
*/
async function applyBranding(pdfDoc, { rgb, StandardFonts }) {
const font = await pdfDoc.embedFont(StandardFonts.HelveticaOblique);
const pages = pdfDoc.getPages();
const logoPng = createFilesLib.getLogo({
forDarkBackground: false,
format: "buffer",
});
const logoImage = logoPng ? await pdfDoc.embedPng(logoPng) : null;
const logoWidth = 80;
const logoHeight = logoImage
? (logoImage.height / logoImage.width) * logoWidth
: 0;
const marginRight = 20;
const marginBottom = 20;
for (const page of pages) {
const { width } = page.getSize();
if (logoImage) {
const createdWithText = "created with";
const fontSize = 7;
const textWidth = font.widthOfTextAtSize(createdWithText, fontSize);
const logoX = width - marginRight - logoWidth;
page.drawText(createdWithText, {
x: logoX + (logoWidth - textWidth) / 2,
y: marginBottom + logoHeight + 2,
size: fontSize,
font,
color: rgb(0.6, 0.6, 0.6),
opacity: 0.6,
});
page.drawImage(logoImage, {
x: logoX,
y: marginBottom,
width: logoWidth,
height: logoHeight,
opacity: 0.6,
});
} else {
const fallbackText = "Created with AnythingLLM";
const fontSize = 9;
const textWidth = font.widthOfTextAtSize(fallbackText, fontSize);
page.drawText(fallbackText, {
x: width - marginRight - textWidth,
y: marginBottom,
size: fontSize,
font,
color: rgb(0.6, 0.6, 0.6),
});
}
}
}
module.exports = {
applyBranding,
};

View File

@@ -0,0 +1,342 @@
const createFilesLib = require("../lib.js");
const { getTheme, getAvailableThemes } = require("./themes.js");
const {
renderTitleSlide,
renderSectionSlide,
renderContentSlide,
renderBlankSlide,
} = require("./utils.js");
const { runSectionAgent } = require("./section-agent.js");
/**
* Extracts recent conversation history from the parent AIbitat's chat log
* to provide context to each section sub-agent.
* @param {Array} chats - The parent AIbitat's _chats array
* @param {number} [maxMessages=10] - Maximum messages to include
* @returns {string} Formatted conversation context
*/
function extractConversationContext(chats, maxMessages = 10) {
if (!Array.isArray(chats) || chats.length === 0) return "";
const recent = chats
.filter((c) => c.state === "success" && c.content)
.slice(-maxMessages);
if (recent.length === 0) return "";
return recent
.map((c) => {
const content =
typeof c.content === "string" ? c.content.substring(0, 500) : "";
return `${c.from}: ${content}`;
})
.join("\n");
}
module.exports.CreatePptxPresentation = {
name: "create-pptx-presentation",
plugin: function () {
return {
name: "create-pptx-presentation",
setup(aibitat) {
aibitat.function({
super: aibitat,
name: this.name,
description:
"Create a professional PowerPoint presentation (PPTX). " +
"Provide a title, theme, and section outlines with key points. " +
"Each section is independently researched and built by a focused sub-agent " +
"that can use web search and web scraping to gather data.",
examples: [
{
prompt: "Create a presentation about project updates",
call: JSON.stringify({
filename: "project-updates.pptx",
title: "Q1 Project Updates",
theme: "corporate",
sections: [
{
title: "Overview",
keyPoints: [
"Project on track for Q1 delivery",
"Team expanded by 2 new members",
"Budget within expectations",
],
},
{
title: "Key Achievements",
keyPoints: [
"Launched new feature X",
"Reduced bug count by 40%",
"Improved performance by 25%",
],
instructions:
"Include specific metrics and quarter-over-quarter comparisons",
},
],
}),
},
{
prompt: "Create a dark themed presentation about AI trends",
call: JSON.stringify({
filename: "ai-trends.pptx",
title: "AI Trends 2025",
theme: "dark",
sections: [
{
title: "Large Language Models",
keyPoints: [
"Model scaling trends",
"Open vs closed source landscape",
],
instructions:
"Research the latest developments and include recent data",
},
{
title: "AI in Enterprise",
keyPoints: ["Adoption rates", "Top use cases", "ROI data"],
},
],
}),
},
],
parameters: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
filename: {
type: "string",
description:
"The filename for the presentation (should end with .pptx).",
},
title: {
type: "string",
description:
"The title of the presentation (shown on title slide).",
},
author: {
type: "string",
description:
"Optional author name for the presentation metadata.",
},
theme: {
type: "string",
enum: getAvailableThemes(),
description:
"Color theme for the presentation. Options: " +
getAvailableThemes().join(", "),
},
sections: {
type: "array",
description:
"Section outlines for the presentation. Each section is independently researched and built by a focused sub-agent.",
items: {
type: "object",
properties: {
title: {
type: "string",
description: "The section title.",
},
keyPoints: {
type: "array",
items: { type: "string" },
description:
"Key points this section should cover. The sub-agent will expand these into detailed slides.",
},
instructions: {
type: "string",
description:
"Optional guidance for the section builder (e.g. 'research recent statistics', 'compare with competitors', 'include a data table').",
},
},
required: ["title"],
},
},
},
required: ["filename", "title", "sections"],
additionalProperties: false,
},
handler: async function ({
filename = "presentation.pptx",
title = "Untitled Presentation",
author = "",
theme: themeName = "default",
sections = [],
}) {
try {
this.super.handlerProps.log(
`Using the create-pptx-presentation tool.`
);
if (!filename.toLowerCase().endsWith(".pptx"))
filename += ".pptx";
const theme = getTheme(themeName);
const totalSections = sections.length;
this.super.introspect(
`${this.caller}: Planning presentation "${title}" — ${totalSections} section${totalSections !== 1 ? "s" : ""}, ${theme.name} theme`
);
// Ask for approval BEFORE kicking off the expensive sub-agent work
if (this.super.requestToolApproval) {
const approval = await this.super.requestToolApproval({
skillName: this.name,
payload: {
filename,
title,
sectionCount: totalSections,
sectionTitles: sections.map((s) => s.title),
},
description: `Create PowerPoint presentation "${title}" with ${totalSections} sections`,
});
if (!approval.approved) {
this.super.introspect(
`${this.caller}: User rejected the ${this.name} request.`
);
return approval.message;
}
}
const conversationContext = extractConversationContext(
this.super._chats
);
// Run a focused sub-agent for each section sequentially.
// Sequential execution is intentional — local models typically serve
// one request at a time, and it keeps introspection events ordered.
const allSlides = [];
const allCitations = [];
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
this.super.introspect(
`${this.caller}: [${i + 1}/${totalSections}] Building section "${section.title}"…`
);
const sectionResult = await runSectionAgent({
parentAibitat: this.super,
section,
presentationTitle: title,
conversationContext,
sectionPrefix: `${i + 1}/${totalSections}`,
});
const slideCount = sectionResult.slides?.length || 0;
allSlides.push(...(sectionResult.slides || []));
if (sectionResult.citations?.length > 0)
allCitations.push(...sectionResult.citations);
this.super.introspect(
`${this.caller}: [${i + 1}/${totalSections}] Section "${section.title}" complete — ${slideCount} slide${slideCount !== 1 ? "s" : ""}`
);
}
// Roll up all citations from sub-agents to the parent so they
// appear as sources on the final assistant message.
if (allCitations.length > 0) this.super.addCitation(allCitations);
// Assemble the final PPTX from all section outputs
this.super.introspect(
`${this.caller}: Assembling final deck — ${allSlides.length} slides total`
);
const PptxGenJS = require("pptxgenjs");
const pptx = new PptxGenJS();
pptx.title = title;
if (author) pptx.author = author;
pptx.company = "AnythingLLM";
const totalSlideCount = allSlides.length;
// Title slide
const titleSlide = pptx.addSlide();
renderTitleSlide(titleSlide, pptx, { title, author }, theme);
// Render every slide produced by the section agents
allSlides.forEach((slideData, index) => {
const slide = pptx.addSlide();
const slideNumber = index + 1;
const layout = slideData.layout || "content";
switch (layout) {
case "title":
case "section":
renderSectionSlide(
slide,
pptx,
slideData,
theme,
slideNumber,
totalSlideCount
);
break;
case "blank":
renderBlankSlide(
slide,
pptx,
theme,
slideNumber,
totalSlideCount
);
break;
default:
renderContentSlide(
slide,
pptx,
slideData,
theme,
slideNumber,
totalSlideCount
);
break;
}
});
const buffer = await pptx.write({ outputType: "nodebuffer" });
const bufferSizeKB = (buffer.length / 1024).toFixed(2);
const bufferSizeMB = (buffer.length / (1024 * 1024)).toFixed(2);
this.super.handlerProps.log(
`create-pptx-presentation: Generated buffer - size: ${bufferSizeKB}KB (${bufferSizeMB}MB), slides: ${totalSlideCount}, theme: ${theme.name}`
);
const displayFilename = filename.split("/").pop();
const savedFile = await createFilesLib.saveGeneratedFile({
fileType: "pptx",
extension: "pptx",
buffer,
displayFilename,
});
this.super.socket.send("fileDownloadCard", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
createFilesLib.registerOutput(this.super, "PptxFileDownload", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
this.super.introspect(
`${this.caller}: Successfully created presentation "${title}"`
);
return `Successfully created presentation "${title}" with ${totalSlideCount} slides across ${totalSections} sections using the ${theme.name} theme.`;
} catch (e) {
this.super.handlerProps.log(
`create-pptx-presentation error: ${e.message}`
);
this.super.introspect(`Error: ${e.message}`);
return `Error creating presentation: ${e.message}`;
}
},
});
},
};
},
};

View File

@@ -0,0 +1,257 @@
const AIbitat = require("../../../index.js");
const SECTION_BUILDER_PROMPT = `You are a focused presentation section builder. Your ONLY task is to create detailed slides for ONE section of a PowerPoint presentation.
You have access to web search and web scraping tools, but only use them when the topic genuinely requires up-to-date information you don't already know (e.g., current statistics, recent events, specific company data). For general knowledge topics, create slides directly from your existing knowledge.
RULES:
- Create 2-5 slides for this section (no more)
- Each content slide should have 3-6 concise bullet points
- Be specific and data-driven when possible
- Include speaker notes with key talking points
- Do NOT add a title slide - only section content
When finished, you MUST call the submit-section-slides tool with your slides. Do not respond with raw JSON - always use the tool.
Available slide layouts:
- "section": Divider slide with title + optional subtitle
- "content": Bullet points with title + content array + optional notes
- May include "table": { "headers": ["Col1", "Col2"], "rows": [["a", "b"]] }
- "blank": Empty slide`;
/**
* Spawns a focused child AIbitat agent to build slides for a single presentation section.
* The child reuses the parent's provider/model/socket so introspection events (tool calls,
* research progress) flow to the frontend in real-time.
*
* @param {Object} options
* @param {AIbitat} options.parentAibitat - The parent AIbitat instance (provides provider, socket, introspect)
* @param {Object} options.section - Section definition { title, keyPoints?, instructions? }
* @param {string} options.presentationTitle - Overall presentation title for context
* @param {string} [options.conversationContext] - Recent conversation history for context
* @param {string} [options.sectionPrefix] - Progress indicator like "1/5" for UI display
* @returns {Promise<{slides: Object[], citations: Object[]}>} Parsed section slides and accumulated citations
*/
async function runSectionAgent({
parentAibitat,
section,
presentationTitle,
conversationContext = "",
sectionPrefix = "",
}) {
const log = parentAibitat.handlerProps?.log || console.log;
const childAibitat = new AIbitat({
provider: parentAibitat.defaultProvider.provider,
model: parentAibitat.defaultProvider.model,
chats: [],
handlerProps: parentAibitat.handlerProps,
maxToolCalls: 5,
});
// Share introspect so tool activity (web-search status, etc.) streams to the frontend
childAibitat.introspect = parentAibitat.introspect;
// Filtered socket: pass through introspection but suppress reportStreamEvent
// so sub-agent chatter doesn't render in the UI as a chat message.
childAibitat.socket = {
send: (type, content) => {
if (type === "reportStreamEvent") return;
parentAibitat.socket?.send(type, content);
},
};
// Only load the research tools this sub-agent needs
const { webBrowsing } = require("../../web-browsing.js");
const { webScraping } = require("../../web-scraping.js");
childAibitat.use(webBrowsing.plugin());
childAibitat.use(webScraping.plugin());
// Internal tool for structured slide submission - not exposed as a public plugin
childAibitat.function({
super: childAibitat,
name: "submit-section-slides",
description:
"Submit the completed slides for this presentation section. Call this tool when you have finished creating all slides.",
parameters: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
slides: {
type: "array",
description: "Array of slide objects for this section",
items: {
type: "object",
properties: {
layout: {
type: "string",
enum: ["section", "content", "blank"],
description: "The slide layout type",
},
title: {
type: "string",
description: "The slide title",
},
subtitle: {
type: "string",
description: "Optional subtitle (for section layout)",
},
content: {
type: "array",
items: { type: "string" },
description: "Bullet points (for content layout)",
},
notes: {
type: "string",
description: "Speaker notes for this slide",
},
table: {
type: "object",
description: "Optional table data",
properties: {
headers: {
type: "array",
items: { type: "string" },
},
rows: {
type: "array",
items: {
type: "array",
items: { type: "string" },
},
},
},
},
},
required: ["layout", "title"],
},
},
},
required: ["slides"],
additionalProperties: false,
},
handler: function ({ slides }) {
this.super._submittedSlides = slides;
return "Slides submitted successfully. Section complete.";
},
});
const functions = Array.from(childAibitat.functions.values());
const messages = [
{ role: "system", content: SECTION_BUILDER_PROMPT },
{
role: "user",
content: buildSectionPrompt({
section,
presentationTitle,
conversationContext,
}),
},
];
const provider = childAibitat.getProviderForConfig(
childAibitat.defaultProvider
);
provider.attachHandlerProps(childAibitat.handlerProps);
log(
`[SectionAgent] Running sub-agent for section: "${section.title}" with ${functions.length} tools`
);
let agentName = `@section-builder`;
if (sectionPrefix) agentName = `[${sectionPrefix}] ${agentName}`;
try {
if (provider.supportsAgentStreaming) {
await childAibitat.handleAsyncExecution(
provider,
messages,
functions,
agentName
);
} else {
await childAibitat.handleExecution(
provider,
messages,
functions,
agentName
);
}
} catch (error) {
log(`[SectionAgent] Error in section "${section.title}": ${error.message}`);
return { ...buildFallbackSlides(section), citations: [] };
}
// Collect any citations the child accumulated (from web-search, web-scrape, etc.)
const citations = childAibitat._pendingCitations || [];
// Retrieve slides from the tool call (structured data, no parsing needed)
const slides = childAibitat._submittedSlides;
if (!Array.isArray(slides) || slides.length === 0) {
log(
`[SectionAgent] No slides submitted for "${section.title}", using fallback`
);
return { ...buildFallbackSlides(section), citations };
}
log(
`[SectionAgent] Section "${section.title}" produced ${slides.length} slides, ${citations.length} citations`
);
return { slides, citations };
}
function buildSectionPrompt({
section,
presentationTitle,
conversationContext,
}) {
const parts = [
`Build slides for this section of the presentation "${presentationTitle}":`,
`\nSection Title: ${section.title}`,
];
if (section.keyPoints?.length > 0) {
parts.push(
`\nKey Points to Cover:\n${section.keyPoints.map((p) => `- ${p}`).join("\n")}`
);
}
if (section.instructions) {
parts.push(`\nSpecial Instructions: ${section.instructions}`);
}
if (conversationContext) {
parts.push(`\nContext from the conversation:\n${conversationContext}`);
}
parts.push(
`\nCreate 2-5 detailed slides and submit them using the submit-section-slides tool. Only use web search/scraping if you genuinely lack the information needed.`
);
return parts.join("\n");
}
/**
* Generates basic slides from the section definition when the sub-agent fails.
*/
function buildFallbackSlides(section) {
const slides = [
{
layout: "section",
title: section.title,
subtitle: section.subtitle || "",
},
];
if (section.keyPoints?.length > 0) {
slides.push({
layout: "content",
title: section.title,
content: section.keyPoints,
notes: `Key points for ${section.title}`,
});
}
return { slides };
}
module.exports = { runSectionAgent };

View File

@@ -0,0 +1,143 @@
/**
* Generate a preview presentation for every theme using the same rendering
* pipeline as the production tool. Run from repo root:
*
* node server/utils/agents/aibitat/plugins/create-files/pptx/test-themes.js
*
* Output → storage/generated-files/theme-previews/
*/
const path = require("path");
const fs = require("fs");
const PptxGenJS = require("pptxgenjs");
const createFilesLib = require("../lib.js");
const { getTheme, getAvailableThemes } = require("./themes.js");
const {
renderTitleSlide,
renderSectionSlide,
renderContentSlide,
renderBlankSlide,
} = require("./utils.js");
const SAMPLE_SLIDES = [
{
title: "Executive Summary",
content: [
"Revenue grew 23% year-over-year to $4.2B",
"Operating margin expanded 180bps to 28.4%",
"Customer retention rate improved to 94.7%",
"Three strategic acquisitions completed in Q3",
],
notes: "Emphasize the margin expansion story",
},
{
layout: "section",
title: "Strategic Priorities",
subtitle: "Key initiatives for the next fiscal year",
},
{
title: "Market Opportunity",
subtitle: "Total addressable market analysis",
content: [
"Global TAM estimated at $180B by 2027",
"Our serviceable market represents $42B opportunity",
"Current market share: 8.3% with clear path to 15%",
"Three adjacent markets identified for expansion",
"Competitive moat strengthening through R&D investment",
],
},
{
title: "Financial Performance",
table: {
headers: ["Metric", "FY2024", "FY2025", "Growth"],
rows: [
["Revenue", "$3.4B", "$4.2B", "+23%"],
["Gross Margin", "62.1%", "64.8%", "+270bps"],
["Operating Income", "$910M", "$1.19B", "+31%"],
["Free Cash Flow", "$780M", "$1.02B", "+31%"],
],
},
},
{
title: "Next Steps & Timeline",
content: [
"Q1: Launch Phase 2 of platform modernization",
"Q2: Complete integration of acquired entities",
"Q3: Enter two new geographic markets",
"Q4: Achieve $5B annual revenue run-rate",
],
},
];
async function generateThemePreview(themeName, outputDir) {
const theme = getTheme(themeName);
const pptx = new PptxGenJS();
pptx.title = `${theme.name} Theme Preview`;
pptx.author = "AnythingLLM";
pptx.company = "AnythingLLM";
const totalSlides = SAMPLE_SLIDES.length;
const titleSlide = pptx.addSlide();
renderTitleSlide(
titleSlide,
pptx,
{ title: `${theme.name} Theme`, author: "AnythingLLM Theme Preview" },
theme
);
SAMPLE_SLIDES.forEach((slideData, index) => {
const slide = pptx.addSlide();
const slideNumber = index + 1;
const layout = slideData.layout || "content";
switch (layout) {
case "title":
case "section":
renderSectionSlide(
slide,
pptx,
slideData,
theme,
slideNumber,
totalSlides
);
break;
case "blank":
renderBlankSlide(slide, pptx, theme, slideNumber, totalSlides);
break;
default:
renderContentSlide(
slide,
pptx,
slideData,
theme,
slideNumber,
totalSlides
);
break;
}
});
const filename = `theme-preview-${themeName}.pptx`;
const filepath = path.join(outputDir, filename);
await pptx.writeFile({ fileName: filepath });
console.log(`${theme.name}${filename}`);
}
async function main() {
const baseDir = await createFilesLib.getOutputDirectory();
const outputDir = path.join(baseDir, "theme-previews");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
console.log("Generating theme previews…\n");
const themes = getAvailableThemes();
for (const themeName of themes) {
await generateThemePreview(themeName, outputDir);
}
console.log(`\nDone! ${themes.length} previews saved to:\n ${outputDir}`);
}
main().catch(console.error);

View File

@@ -0,0 +1,181 @@
/**
* Curated presentation themes for pptxgenjs.
*
* Each theme is a complete design system: title-slide palette, content-slide
* palette, table styling, footer colors, and typography. The rendering code
* in utils.js consumes these tokens to produce consistent, professional slides.
*
* Themes: default · corporate · dark · minimal · creative
*/
const THEMES = {
default: {
name: "Professional",
description: "Clean and versatile — works for any presentation",
titleSlideBackground: "1E293B",
titleSlideTitleColor: "FFFFFF",
titleSlideSubtitleColor: "94A3B8",
titleSlideAccentColor: "3B82F6",
background: "FFFFFF",
titleColor: "0F172A",
subtitleColor: "64748B",
bodyColor: "334155",
accentColor: "2563EB",
bulletColor: "2563EB",
tableHeaderBg: "1E293B",
tableHeaderColor: "FFFFFF",
tableAltRowBg: "F8FAFC",
tableBorderColor: "E2E8F0",
footerColor: "94A3B8",
footerLineColor: "E2E8F0",
fontTitle: "Calibri",
fontBody: "Calibri",
},
corporate: {
name: "Corporate",
description: "Refined and authoritative — ideal for business and finance",
titleSlideBackground: "0C1929",
titleSlideTitleColor: "FFFFFF",
titleSlideSubtitleColor: "7B96B5",
titleSlideAccentColor: "C9943E",
background: "FFFFFF",
titleColor: "0C1929",
subtitleColor: "5A6D82",
bodyColor: "2C3E50",
accentColor: "1A5276",
bulletColor: "1A5276",
tableHeaderBg: "0C1929",
tableHeaderColor: "FFFFFF",
tableAltRowBg: "F4F7FA",
tableBorderColor: "D5DBE2",
footerColor: "8B9DB3",
footerLineColor: "D5DBE2",
fontTitle: "Calibri",
fontBody: "Calibri",
},
dark: {
name: "Dark",
description: "Sleek dark theme — great for tech and product presentations",
titleSlideBackground: "0F0F1A",
titleSlideTitleColor: "F8FAFC",
titleSlideSubtitleColor: "7C8DB5",
titleSlideAccentColor: "818CF8",
background: "18181B",
titleColor: "F4F4F5",
subtitleColor: "A1A1AA",
bodyColor: "D4D4D8",
accentColor: "6366F1",
bulletColor: "818CF8",
tableHeaderBg: "6366F1",
tableHeaderColor: "FFFFFF",
tableAltRowBg: "1F1F24",
tableBorderColor: "3F3F46",
footerColor: "71717A",
footerLineColor: "3F3F46",
fontTitle: "Calibri",
fontBody: "Calibri",
},
minimal: {
name: "Minimal",
description: "Ultra-clean with maximum whitespace — lets content speak",
titleSlideBackground: "F5F5F5",
titleSlideTitleColor: "171717",
titleSlideSubtitleColor: "737373",
titleSlideAccentColor: "A3A3A3",
background: "FFFFFF",
titleColor: "171717",
subtitleColor: "737373",
bodyColor: "404040",
accentColor: "525252",
bulletColor: "A3A3A3",
tableHeaderBg: "262626",
tableHeaderColor: "FFFFFF",
tableAltRowBg: "FAFAFA",
tableBorderColor: "E5E5E5",
footerColor: "A3A3A3",
footerLineColor: "E5E5E5",
fontTitle: "Calibri",
fontBody: "Calibri Light",
},
creative: {
name: "Creative",
description: "Bold and expressive — perfect for pitches and creative work",
titleSlideBackground: "2E1065",
titleSlideTitleColor: "FFFFFF",
titleSlideSubtitleColor: "C4B5FD",
titleSlideAccentColor: "A78BFA",
background: "FFFFFF",
titleColor: "3B0764",
subtitleColor: "7C3AED",
bodyColor: "374151",
accentColor: "7C3AED",
bulletColor: "7C3AED",
tableHeaderBg: "5B21B6",
tableHeaderColor: "FFFFFF",
tableAltRowBg: "FAF5FF",
tableBorderColor: "E9D5FF",
footerColor: "A78BFA",
footerLineColor: "E9D5FF",
fontTitle: "Calibri",
fontBody: "Calibri",
},
};
/**
* Get a theme by name, falling back to default if not found.
* @param {string} themeName
* @returns {object} Theme configuration
*/
function getTheme(themeName) {
const key = (themeName || "default").toLowerCase().trim();
return THEMES[key] || THEMES.default;
}
/**
* @returns {string[]} Available theme identifiers
*/
function getAvailableThemes() {
return Object.keys(THEMES);
}
/**
* @returns {object[]} Array of { id, name, description } for documentation
*/
function getThemeDescriptions() {
return Object.entries(THEMES).map(([id, t]) => ({
id,
name: t.name,
description: t.description,
}));
}
module.exports = { THEMES, getTheme, getAvailableThemes, getThemeDescriptions };

View File

@@ -0,0 +1,378 @@
const createFilesLib = require("../lib.js");
// All positioning assumes LAYOUT_16x9: 10 × 5.625 in.
const MARGIN_X = 0.7;
const CONTENT_W = 8.6; // 10 - 2 × MARGIN_X
const SLIDE_H = 5.625;
function isDarkColor(hexColor) {
const hex = (hexColor || "FFFFFF").replace("#", "");
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5;
}
function addBranding(slide, bgColor) {
const isDark = isDarkColor(bgColor);
const textColor = isDark ? "FFFFFF" : "000000";
const logo = createFilesLib.getLogo({
forDarkBackground: isDark,
format: "dataUri",
});
slide.addText("Created with", {
x: 7.85,
y: 5.06,
w: 1.85,
h: 0.12,
fontSize: 5.5,
color: textColor,
transparency: 78,
fontFace: "Calibri",
align: "center",
italic: true,
});
if (logo) {
slide.addImage({
data: logo,
x: 8.025,
y: 5.17,
w: 1.5,
h: 0.24,
transparency: 78,
});
} else {
slide.addText("AnythingLLM", {
x: 7.85,
y: 5.17,
w: 1.85,
h: 0.24,
fontSize: 8,
color: textColor,
transparency: 78,
fontFace: "Calibri",
align: "center",
});
}
}
function addTopAccentBar(slide, pptx, theme) {
slide.addShape(pptx.ShapeType.rect, {
x: 0,
y: 0,
w: "100%",
h: 0.05,
fill: { color: theme.accentColor },
line: { color: theme.accentColor },
});
}
function addAccentUnderline(slide, pptx, x, y, color) {
slide.addShape(pptx.ShapeType.rect, {
x,
y,
w: 1.5,
h: 0.035,
fill: { color },
line: { color },
});
}
function addSlideFooter(slide, pptx, theme, slideNumber, totalSlides) {
slide.addShape(pptx.ShapeType.rect, {
x: MARGIN_X,
y: 5.0,
w: CONTENT_W,
h: 0.007,
fill: { color: theme.footerLineColor },
line: { color: theme.footerLineColor },
});
slide.addText(`${slideNumber} / ${totalSlides}`, {
x: MARGIN_X,
y: 5.07,
w: 1.2,
h: 0.25,
fontSize: 8,
color: theme.footerColor,
fontFace: theme.fontBody,
align: "left",
});
}
function renderTitleSlide(slide, pptx, { title, author }, theme) {
slide.background = { color: theme.titleSlideBackground };
slide.addText(title || "Untitled", {
x: 1.0,
y: 1.3,
w: 8.0,
h: 1.4,
fontSize: 36,
bold: true,
color: theme.titleSlideTitleColor,
fontFace: theme.fontTitle,
align: "center",
valign: "bottom",
});
addAccentUnderline(slide, pptx, 4.25, 2.9, theme.titleSlideAccentColor);
if (author) {
slide.addText(author, {
x: 1.5,
y: 3.15,
w: 7.0,
h: 0.45,
fontSize: 14,
color: theme.titleSlideSubtitleColor,
fontFace: theme.fontBody,
align: "center",
italic: true,
});
}
// Bottom accent strip
slide.addShape(pptx.ShapeType.rect, {
x: 0,
y: SLIDE_H - 0.1,
w: "100%",
h: 0.1,
fill: { color: theme.titleSlideAccentColor },
line: { color: theme.titleSlideAccentColor },
});
addBranding(slide, theme.titleSlideBackground);
}
function renderSectionSlide(
slide,
pptx,
slideData,
theme,
slideNumber,
totalSlides
) {
slide.background = { color: theme.titleSlideBackground };
slide.addText(slideData.title || "", {
x: 1.0,
y: 1.5,
w: 8.0,
h: 1.2,
fontSize: 32,
bold: true,
color: theme.titleSlideTitleColor,
fontFace: theme.fontTitle,
align: "center",
valign: "bottom",
});
addAccentUnderline(slide, pptx, 4.25, 2.9, theme.titleSlideAccentColor);
if (slideData.subtitle) {
slide.addText(slideData.subtitle, {
x: 1.5,
y: 3.1,
w: 7.0,
h: 0.5,
fontSize: 16,
color: theme.titleSlideSubtitleColor,
fontFace: theme.fontBody,
align: "center",
});
}
const numColor = isDarkColor(theme.titleSlideBackground)
? "FFFFFF"
: "000000";
slide.addText(`${slideNumber} / ${totalSlides}`, {
x: MARGIN_X,
y: 5.1,
w: 1.2,
h: 0.25,
fontSize: 8,
color: numColor,
transparency: 65,
fontFace: theme.fontBody,
align: "left",
});
addBranding(slide, theme.titleSlideBackground);
if (slideData.notes) slide.addNotes(slideData.notes);
}
function renderContentSlide(
slide,
pptx,
slideData,
theme,
slideNumber,
totalSlides
) {
slide.background = { color: theme.background };
addTopAccentBar(slide, pptx, theme);
let contentStartY = 0.4;
if (slideData.title) {
slide.addText(slideData.title, {
x: MARGIN_X,
y: 0.3,
w: CONTENT_W,
h: 0.65,
fontSize: 24,
bold: true,
color: theme.titleColor,
fontFace: theme.fontTitle,
valign: "bottom",
});
contentStartY = 1.0;
if (slideData.subtitle) {
slide.addText(slideData.subtitle, {
x: MARGIN_X,
y: 1.0,
w: CONTENT_W,
h: 0.3,
fontSize: 13,
color: theme.subtitleColor,
fontFace: theme.fontBody,
});
contentStartY = 1.35;
}
addAccentUnderline(
slide,
pptx,
MARGIN_X,
contentStartY + 0.05,
theme.accentColor
);
contentStartY += 0.25;
}
const footerY = 5.0;
const contentHeight = footerY - contentStartY - 0.15;
if (slideData.table) {
addTableContent(slide, pptx, slideData.table, theme, contentStartY);
} else {
addBulletContent(
slide,
slideData.content,
theme,
contentStartY,
contentHeight
);
}
addSlideFooter(slide, pptx, theme, slideNumber, totalSlides);
addBranding(slide, theme.background);
if (slideData.notes) slide.addNotes(slideData.notes);
}
function renderBlankSlide(slide, pptx, theme, slideNumber, totalSlides) {
slide.background = { color: theme.background };
addSlideFooter(slide, pptx, theme, slideNumber, totalSlides);
addBranding(slide, theme.background);
}
function addBulletContent(slide, content, theme, startY, maxHeight) {
if (!Array.isArray(content) || content.length === 0) return;
const bulletPoints = content.map((text) => ({
text,
options: {
fontSize: 15,
color: theme.bodyColor,
fontFace: theme.fontBody,
bullet: { code: "25AA", color: theme.bulletColor },
paraSpaceAfter: 10,
},
}));
slide.addText(bulletPoints, {
x: MARGIN_X,
y: startY,
w: CONTENT_W,
h: maxHeight,
valign: "top",
});
}
function addTableContent(slide, pptx, tableData, theme, startY) {
if (!tableData) return;
const rows = [];
if (tableData.headers?.length > 0) {
rows.push(
tableData.headers.map((header) => ({
text: header,
options: {
bold: true,
fontSize: 12,
fontFace: theme.fontBody,
color: theme.tableHeaderColor,
fill: { color: theme.tableHeaderBg },
align: "left",
valign: "middle",
margin: [4, 8, 4, 8],
},
}))
);
}
if (tableData.rows?.length > 0) {
tableData.rows.forEach((row, idx) => {
rows.push(
row.map((cell) => ({
text: cell,
options: {
fontSize: 11,
fontFace: theme.fontBody,
color: theme.bodyColor,
fill: {
color: idx % 2 === 1 ? theme.tableAltRowBg : theme.background,
},
align: "left",
valign: "middle",
margin: [4, 8, 4, 8],
},
}))
);
});
}
if (rows.length === 0) return;
const colCount = rows[0].length;
slide.addTable(rows, {
x: MARGIN_X,
y: startY,
w: CONTENT_W,
colW: CONTENT_W / colCount,
rowH: 0.4,
border: { type: "solid", pt: 0.5, color: theme.tableBorderColor },
});
}
module.exports = {
isDarkColor,
addBranding,
addTopAccentBar,
addAccentUnderline,
addSlideFooter,
renderTitleSlide,
renderSectionSlide,
renderContentSlide,
renderBlankSlide,
addBulletContent,
addTableContent,
};

View File

@@ -0,0 +1,158 @@
const createFilesLib = require("../lib.js");
module.exports.CreateTextFile = {
name: "create-text-file",
plugin: function () {
return {
name: "create-text-file",
setup(aibitat) {
aibitat.function({
super: aibitat,
name: this.name,
description:
"Create a text file with arbitrary content. " +
"Provide the content and an optional file extension (defaults to .txt). " +
"Common extensions include .txt, .md, .json, .csv, .html, .xml, .yaml, .log, etc.",
examples: [
{
prompt: "Create a text file with meeting notes",
call: JSON.stringify({
filename: "meeting-notes.txt",
content:
"Meeting Notes - Q1 Planning\n\nAttendees: John, Sarah, Mike\n\nAgenda:\n1. Review Q4 results\n2. Set Q1 goals\n3. Assign tasks\n\nAction Items:\n- John: Prepare budget report\n- Sarah: Draft marketing plan\n- Mike: Schedule follow-up meeting",
}),
},
{
prompt: "Create a markdown file with project documentation",
call: JSON.stringify({
filename: "README.md",
extension: "md",
content:
"# Project Name\n\n## Overview\nThis project provides...\n\n## Installation\n```bash\nnpm install\n```\n\n## Usage\nRun the application with...",
}),
},
{
prompt: "Create a JSON configuration file",
call: JSON.stringify({
filename: "config.json",
extension: "json",
content: JSON.stringify(
{
appName: "MyApp",
version: "1.0.0",
settings: {
debug: false,
maxConnections: 100,
},
},
null,
2
),
}),
},
],
parameters: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
filename: {
type: "string",
description:
"The filename for the text file. If no extension is provided, the extension parameter will be used (defaults to .txt).",
},
extension: {
type: "string",
description:
"The file extension to use (without the dot). Defaults to 'txt'. Common options: txt, md, json, csv, html, xml, yaml, log, etc.",
default: "txt",
},
content: {
type: "string",
description:
"The text content to write to the file. Can be any arbitrary text including multi-line content.",
},
},
required: ["filename", "content"],
additionalProperties: false,
},
handler: async function ({
filename = "document.txt",
extension = "txt",
content = "",
}) {
try {
this.super.handlerProps.log(`Using the create-text-file tool.`);
const normalizedExt = extension.toLowerCase().replace(/^\./, "");
const hasExtension = /\.\w+$/.test(filename);
if (!hasExtension) {
filename = `${filename}.${normalizedExt}`;
}
const finalExtension = filename.split(".").pop().toLowerCase();
this.super.introspect(
`${this.caller}: Creating text file "${filename}"`
);
const buffer = Buffer.from(content, "utf-8");
const bufferSizeKB = (buffer.length / 1024).toFixed(2);
this.super.handlerProps.log(
`create-text-file: Generated buffer - size: ${bufferSizeKB}KB, extension: ${finalExtension}`
);
if (this.super.requestToolApproval) {
const approval = await this.super.requestToolApproval({
skillName: this.name,
payload: { filename, extension: finalExtension },
description: `Create text file "${filename}"`,
});
if (!approval.approved) {
this.super.introspect(
`${this.caller}: User rejected the ${this.name} request.`
);
return approval.message;
}
}
const displayFilename = filename.split("/").pop();
const savedFile = await createFilesLib.saveGeneratedFile({
fileType: "text",
extension: finalExtension,
buffer,
displayFilename,
});
this.super.socket.send("fileDownloadCard", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
createFilesLib.registerOutput(this.super, "TextFileDownload", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
this.super.introspect(
`${this.caller}: Successfully created text file "${displayFilename}"`
);
return `Successfully created text file "${displayFilename}" (${bufferSizeKB}KB).`;
} catch (e) {
this.super.handlerProps.log(
`create-text-file error: ${e.message}`
);
this.super.introspect(`Error: ${e.message}`);
return `Error creating text file: ${e.message}`;
}
},
});
},
};
},
};

View File

@@ -0,0 +1,362 @@
const createFilesLib = require("../lib.js");
const {
parseCSV,
validateCSVData,
detectDelimiter,
inferCellType,
applyBranding,
autoFitColumns,
applyHeaderStyle,
applyZebraStriping,
freezePanes,
} = require("./utils.js");
module.exports.CreateExcelFile = {
name: "create-excel-file",
plugin: function () {
return {
name: "create-excel-file",
setup(aibitat) {
aibitat.function({
super: aibitat,
name: this.name,
description:
"Create an Excel spreadsheet (.xlsx) from CSV data. " +
"Supports multiple sheets, automatic type detection (numbers, dates, booleans), " +
"header styling, column auto-fit, zebra striping, and frozen panes. " +
"Provide data in CSV format with comma, semicolon, tab, or pipe delimiters.",
examples: [
{
prompt: "Create an Excel file with sales data",
call: JSON.stringify({
filename: "sales-report.xlsx",
sheets: [
{
name: "Q1 Sales",
csvData:
"Product,Region,Sales,Date\nWidget A,North,1250.50,2024-01-15\nWidget B,South,980.00,2024-01-20\nWidget C,East,1100.25,2024-02-01",
options: {
headerStyle: true,
autoFit: true,
freezeHeader: true,
},
},
],
}),
},
{
prompt: "Create a multi-sheet Excel workbook with employee data",
call: JSON.stringify({
filename: "employee-directory.xlsx",
sheets: [
{
name: "Employees",
csvData:
"ID,Name,Department,Salary,Start Date\n1,John Smith,Engineering,85000,2022-03-15\n2,Jane Doe,Marketing,72000,2021-08-01\n3,Bob Wilson,Sales,68000,2023-01-10",
options: {
headerStyle: true,
zebraStripes: true,
freezeHeader: true,
},
},
{
name: "Departments",
csvData:
"Department,Head,Budget\nEngineering,Alice Brown,500000\nMarketing,Carol White,250000\nSales,Dan Green,300000",
options: { headerStyle: true, autoFit: true },
},
],
}),
},
{
prompt: "Create a simple spreadsheet from CSV data",
call: JSON.stringify({
filename: "data-export.xlsx",
csvData:
"Name,Email,Status\nAlice,alice@example.com,Active\nBob,bob@example.com,Pending\nCharlie,charlie@example.com,Active",
}),
},
],
parameters: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
filename: {
type: "string",
description:
"The filename for the Excel file. The .xlsx extension will be added automatically if not provided.",
},
csvData: {
type: "string",
description:
"CSV data for a single-sheet workbook. Use comma, semicolon, tab, or pipe as delimiter. " +
"For multiple sheets, use the 'sheets' parameter instead.",
},
sheets: {
type: "array",
description:
"Array of sheet definitions for multi-sheet workbooks. Each sheet has a name, csvData, and optional styling options.",
items: {
type: "object",
properties: {
name: {
type: "string",
description:
"The name of the worksheet (max 31 characters).",
},
csvData: {
type: "string",
description: "The CSV data for this sheet.",
},
options: {
type: "object",
description: "Optional styling options for this sheet.",
properties: {
headerStyle: {
type: "boolean",
description:
"Apply styling to the header row (bold, colored background).",
},
autoFit: {
type: "boolean",
description:
"Auto-fit column widths based on content.",
},
freezeHeader: {
type: "boolean",
description:
"Freeze the header row so it stays visible when scrolling.",
},
zebraStripes: {
type: "boolean",
description:
"Apply alternating row colors for better readability.",
},
delimiter: {
type: "string",
description:
"Override auto-detected delimiter. One of: comma, semicolon, tab, pipe.",
},
},
},
},
required: ["name", "csvData"],
},
},
options: {
type: "object",
description:
"Default styling options applied to all sheets (can be overridden per-sheet).",
properties: {
headerStyle: { type: "boolean" },
autoFit: { type: "boolean" },
freezeHeader: { type: "boolean" },
zebraStripes: { type: "boolean" },
},
},
},
required: ["filename"],
additionalProperties: false,
},
handler: async function ({
filename = "spreadsheet.xlsx",
csvData = null,
sheets = null,
options = {},
}) {
try {
this.super.handlerProps.log(`Using the create-excel-file tool.`);
const hasExtension = /\.xlsx$/i.test(filename);
if (!hasExtension) filename = `${filename}.xlsx`;
if (!csvData && (!sheets || sheets.length === 0)) {
return "Error: You must provide either 'csvData' for a single sheet or 'sheets' array for multiple sheets.";
}
const sheetDefinitions = sheets
? sheets
: [
{
name: "Sheet1",
csvData,
options: {},
},
];
for (const sheet of sheetDefinitions) {
if (!sheet.csvData || sheet.csvData.trim() === "") {
return `Error: Sheet "${sheet.name || "unnamed"}" has no CSV data.`;
}
}
const sheetCount = sheetDefinitions.length;
this.super.introspect(
`${this.caller}: Creating Excel file "${filename}" with ${sheetCount} sheet(s)`
);
if (this.super.requestToolApproval) {
const approval = await this.super.requestToolApproval({
skillName: this.name,
payload: {
filename,
sheetCount,
sheetNames: sheetDefinitions.map((s) => s.name),
},
description: `Create Excel spreadsheet "${filename}" with ${sheetCount} sheet(s)`,
});
if (!approval.approved) {
this.super.introspect(
`${this.caller}: User rejected the ${this.name} request.`
);
return approval.message;
}
}
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.default.Workbook();
workbook.creator = "AnythingLLM";
workbook.created = new Date();
workbook.modified = new Date();
const allWarnings = [];
for (const sheetDef of sheetDefinitions) {
let sheetName = (sheetDef.name || "Sheet").substring(0, 31);
sheetName = sheetName.replace(/[*?:\\/[\]]/g, "_");
const sheetOptions = {
...options,
...(sheetDef.options || {}),
};
const delimiterMap = {
comma: ",",
semicolon: ";",
tab: "\t",
pipe: "|",
};
const delimiter = sheetOptions.delimiter
? delimiterMap[sheetOptions.delimiter] ||
sheetOptions.delimiter
: detectDelimiter(sheetDef.csvData);
const parsedData = parseCSV(sheetDef.csvData, delimiter);
const validation = validateCSVData(parsedData);
if (!validation.valid) {
return `Error in sheet "${sheetName}": ${validation.error}`;
}
if (validation.warnings) {
allWarnings.push(
...validation.warnings.map((w) => `${sheetName}: ${w}`)
);
}
const worksheet = workbook.addWorksheet(sheetName);
for (
let rowIndex = 0;
rowIndex < parsedData.length;
rowIndex++
) {
const rowData = parsedData[rowIndex];
const row = worksheet.getRow(rowIndex + 1);
for (
let colIndex = 0;
colIndex < rowData.length;
colIndex++
) {
const cellValue = rowData[colIndex];
const cell = row.getCell(colIndex + 1);
const typedValue =
rowIndex === 0 ? cellValue : inferCellType(cellValue);
cell.value = typedValue;
if (typedValue instanceof Date) {
cell.numFmt = "yyyy-mm-dd";
} else if (
typeof typedValue === "number" &&
cellValue.includes("%")
) {
cell.numFmt = "0.00%";
}
}
row.commit();
}
if (sheetOptions.autoFit !== false) {
autoFitColumns(worksheet);
}
if (sheetOptions.headerStyle !== false) {
applyHeaderStyle(worksheet);
}
if (sheetOptions.zebraStripes) {
applyZebraStriping(worksheet);
}
if (sheetOptions.freezeHeader !== false) {
freezePanes(worksheet, 1, 0);
}
}
applyBranding(workbook);
const buffer = await workbook.xlsx.writeBuffer();
const bufferSizeKB = (buffer.length / 1024).toFixed(2);
const displayFilename = filename.split("/").pop();
this.super.handlerProps.log(
`create-excel-file: Generated buffer - size: ${bufferSizeKB}KB, sheets: ${sheetDefinitions.length}`
);
const savedFile = await createFilesLib.saveGeneratedFile({
fileType: "xlsx",
extension: "xlsx",
buffer: Buffer.from(buffer),
displayFilename,
});
this.super.socket.send("fileDownloadCard", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
createFilesLib.registerOutput(this.super, "ExcelFileDownload", {
filename: savedFile.displayFilename,
storageFilename: savedFile.filename,
fileSize: savedFile.fileSize,
});
this.super.introspect(
`${this.caller}: Successfully created Excel file "${displayFilename}"`
);
let result = `Successfully created Excel spreadsheet "${displayFilename}" (${bufferSizeKB}KB) with ${sheetDefinitions.length} sheet(s).`;
if (allWarnings.length > 0) {
result += `\n\nWarnings:\n${allWarnings.map((w) => `- ${w}`).join("\n")}`;
}
return result;
} catch (e) {
this.super.handlerProps.log(
`create-excel-file error: ${e.message}`
);
this.super.introspect(`Error: ${e.message}`);
return `Error creating Excel file: ${e.message}`;
}
},
});
},
};
},
};

View File

@@ -0,0 +1,333 @@
/**
* Parses CSV string into a 2D array of values.
* Handles quoted fields, embedded commas, and newlines within quotes.
* @param {string} csvString - The CSV content to parse
* @param {string} [delimiter=","] - The field delimiter
* @returns {string[][]} 2D array of parsed values
*/
function parseCSV(csvString, delimiter = ",") {
const rows = [];
let currentRow = [];
let currentField = "";
let inQuotes = false;
for (let i = 0; i < csvString.length; i++) {
const char = csvString[i];
const nextChar = csvString[i + 1];
if (inQuotes) {
if (char === '"' && nextChar === '"') {
currentField += '"';
i++;
} else if (char === '"') {
inQuotes = false;
} else {
currentField += char;
}
} else {
if (char === '"') {
inQuotes = true;
} else if (char === delimiter) {
currentRow.push(currentField.trim());
currentField = "";
} else if (char === "\r" && nextChar === "\n") {
currentRow.push(currentField.trim());
rows.push(currentRow);
currentRow = [];
currentField = "";
i++;
} else if (char === "\n" || char === "\r") {
currentRow.push(currentField.trim());
rows.push(currentRow);
currentRow = [];
currentField = "";
} else {
currentField += char;
}
}
}
if (currentField || currentRow.length > 0) {
currentRow.push(currentField.trim());
rows.push(currentRow);
}
return rows.filter((row) => row.some((cell) => cell !== ""));
}
/**
* Validates CSV data structure.
* @param {string[][]} data - Parsed CSV data
* @returns {{valid: boolean, error?: string, warnings?: string[]}}
*/
function validateCSVData(data) {
const warnings = [];
if (!data || data.length === 0) {
return { valid: false, error: "CSV data is empty" };
}
if (data.length === 1 && data[0].length === 1 && !data[0][0]) {
return { valid: false, error: "CSV data contains no meaningful content" };
}
const columnCounts = data.map((row) => row.length);
const maxColumns = Math.max(...columnCounts);
const minColumns = Math.min(...columnCounts);
if (maxColumns !== minColumns) {
warnings.push(
`Inconsistent column count: rows have between ${minColumns} and ${maxColumns} columns. Missing cells will be empty.`
);
}
if (maxColumns > 16384) {
return {
valid: false,
error: `CSV has ${maxColumns} columns, exceeding Excel's limit of 16,384 columns`,
};
}
if (data.length > 1048576) {
return {
valid: false,
error: `CSV has ${data.length} rows, exceeding Excel's limit of 1,048,576 rows`,
};
}
return { valid: true, warnings: warnings.length > 0 ? warnings : undefined };
}
/**
* Attempts to detect the delimiter used in a CSV string.
* @param {string} csvString - The CSV content
* @returns {string} Detected delimiter (comma, semicolon, tab, or pipe)
*/
function detectDelimiter(csvString) {
const firstLine = csvString.split(/\r?\n/)[0] || "";
const delimiters = [",", ";", "\t", "|"];
let bestDelimiter = ",";
let maxCount = 0;
for (const delimiter of delimiters) {
const count = (firstLine.match(new RegExp(`\\${delimiter}`, "g")) || [])
.length;
if (count > maxCount) {
maxCount = count;
bestDelimiter = delimiter;
}
}
return bestDelimiter;
}
/**
* Attempts to convert a string value to an appropriate type (number, date, boolean, or string).
* @param {string} value - The string value to convert
* @returns {string|number|Date|boolean} The converted value
*/
function inferCellType(value) {
if (value === "" || value === null || value === undefined) {
return "";
}
const trimmed = value.trim();
const lowerTrimmed = trimmed.toLowerCase();
if (lowerTrimmed === "true") return true;
if (lowerTrimmed === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const num = parseFloat(trimmed);
if (!isNaN(num) && isFinite(num)) {
return num;
}
}
if (/^-?\d{1,3}(,\d{3})*(\.\d+)?$/.test(trimmed)) {
const num = parseFloat(trimmed.replace(/,/g, ""));
if (!isNaN(num) && isFinite(num)) {
return num;
}
}
const currencyMatch = trimmed.match(/^[$€£¥₹]?\s*(-?\d+(?:[,.\d]*\d)?)\s*$/);
if (currencyMatch) {
const num = parseFloat(currencyMatch[1].replace(/,/g, ""));
if (!isNaN(num) && isFinite(num)) {
return num;
}
}
if (/^\d+(\.\d+)?%$/.test(trimmed)) {
const num = parseFloat(trimmed) / 100;
if (!isNaN(num) && isFinite(num)) {
return num;
}
}
const datePatterns = [
/^\d{4}-\d{2}-\d{2}$/,
/^\d{2}\/\d{2}\/\d{4}$/,
/^\d{2}-\d{2}-\d{4}$/,
/^\d{4}\/\d{2}\/\d{2}$/,
];
for (const pattern of datePatterns) {
if (pattern.test(trimmed)) {
const date = new Date(trimmed);
if (!isNaN(date.getTime())) {
return date;
}
}
}
return value;
}
/**
* Applies AnythingLLM branding to an Excel workbook.
* Adds a subtle "Created with AnythingLLM" text row below the data on each sheet.
* @param {import('exceljs').Workbook} workbook - The ExcelJS workbook instance
*/
function applyBranding(workbook) {
for (const worksheet of workbook.worksheets) {
const lastRow = worksheet.rowCount || 1;
const lastCol = worksheet.columnCount || 1;
const brandingRowNum = lastRow + 2;
if (lastCol > 1) {
worksheet.mergeCells(brandingRowNum, 1, brandingRowNum, lastCol);
}
const brandingCell = worksheet.getCell(brandingRowNum, 1);
brandingCell.value = "Created with AnythingLLM";
brandingCell.font = {
italic: true,
size: 9,
color: { argb: "FF999999" },
};
brandingCell.alignment = {
horizontal: "right",
vertical: "middle",
};
}
}
/**
* Auto-fits column widths based on content.
* @param {import('exceljs').Worksheet} worksheet - The worksheet to auto-fit
* @param {number} [minWidth=8] - Minimum column width
* @param {number} [maxWidth=50] - Maximum column width
*/
function autoFitColumns(worksheet, minWidth = 8, maxWidth = 50) {
worksheet.columns.forEach((column, colIndex) => {
let maxLength = minWidth;
worksheet.eachRow({ includeEmpty: false }, (row) => {
const cell = row.getCell(colIndex + 1);
const cellValue = cell.value;
let cellLength = minWidth;
if (cellValue !== null && cellValue !== undefined) {
if (typeof cellValue === "string") {
cellLength = cellValue.length;
} else if (cellValue instanceof Date) {
cellLength = 12;
} else if (typeof cellValue === "number") {
cellLength = cellValue.toString().length;
} else if (typeof cellValue === "object" && cellValue.richText) {
cellLength = cellValue.richText.reduce(
(acc, rt) => acc + (rt.text?.length || 0),
0
);
} else {
cellLength = String(cellValue).length;
}
}
maxLength = Math.max(maxLength, cellLength);
});
column.width = Math.min(maxLength + 2, maxWidth);
});
}
/**
* Applies header styling to the first row of a worksheet.
* @param {import('exceljs').Worksheet} worksheet - The worksheet to style
* @param {Object} [options] - Styling options
* @param {boolean} [options.bold=true] - Make headers bold
* @param {string} [options.fill] - Background color (ARGB format, e.g., 'FF4472C4')
* @param {string} [options.fontColor] - Font color (ARGB format, e.g., 'FFFFFFFF')
*/
function applyHeaderStyle(
worksheet,
{ bold = true, fill = "FF4472C4", fontColor = "FFFFFFFF" } = {}
) {
const headerRow = worksheet.getRow(1);
if (!headerRow || headerRow.cellCount === 0) return;
headerRow.eachCell((cell) => {
cell.font = {
bold,
color: { argb: fontColor },
};
cell.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: fill },
};
cell.alignment = {
vertical: "middle",
horizontal: "center",
};
});
headerRow.height = 20;
}
/**
* Applies alternating row colors (zebra striping) to a worksheet.
* @param {import('exceljs').Worksheet} worksheet - The worksheet to style
* @param {string} [evenColor='FFF2F2F2'] - Color for even rows (ARGB format)
* @param {number} [startRow=2] - Row to start alternating from (skips header)
*/
function applyZebraStriping(worksheet, evenColor = "FFF2F2F2", startRow = 2) {
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
if (rowNumber >= startRow && rowNumber % 2 === 0) {
row.eachCell((cell) => {
if (!cell.fill || cell.fill.type !== "pattern") {
cell.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: evenColor },
};
}
});
}
});
}
/**
* Freezes the header row and optionally first columns.
* @param {import('exceljs').Worksheet} worksheet - The worksheet to modify
* @param {number} [rows=1] - Number of rows to freeze
* @param {number} [columns=0] - Number of columns to freeze
*/
function freezePanes(worksheet, rows = 1, columns = 0) {
worksheet.views = [{ state: "frozen", xSplit: columns, ySplit: rows }];
}
module.exports = {
parseCSV,
validateCSVData,
detectDelimiter,
inferCellType,
applyBranding,
autoFitColumns,
applyHeaderStyle,
applyZebraStriping,
freezePanes,
};