mirror of
https://github.com/Mintplex-Labs/anything-llm
synced 2026-04-26 01:25:15 +02:00
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:
@@ -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}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
298
server/utils/agents/aibitat/plugins/create-files/docx/test-themes.js
Executable file
298
server/utils/agents/aibitat/plugins/create-files/docx/test-themes.js
Executable 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);
|
||||
1095
server/utils/agents/aibitat/plugins/create-files/docx/utils.js
Normal file
1095
server/utils/agents/aibitat/plugins/create-files/docx/utils.js
Normal file
File diff suppressed because it is too large
Load Diff
23
server/utils/agents/aibitat/plugins/create-files/index.js
Normal file
23
server/utils/agents/aibitat/plugins/create-files/index.js
Normal 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,
|
||||
};
|
||||
298
server/utils/agents/aibitat/plugins/create-files/lib.js
Normal file
298
server/utils/agents/aibitat/plugins/create-files/lib.js
Normal 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();
|
||||
@@ -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}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
181
server/utils/agents/aibitat/plugins/create-files/pptx/themes.js
Normal file
181
server/utils/agents/aibitat/plugins/create-files/pptx/themes.js
Normal 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 };
|
||||
378
server/utils/agents/aibitat/plugins/create-files/pptx/utils.js
Normal file
378
server/utils/agents/aibitat/plugins/create-files/pptx/utils.js
Normal 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,
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
333
server/utils/agents/aibitat/plugins/create-files/xlsx/utils.js
Normal file
333
server/utils/agents/aibitat/plugins/create-files/xlsx/utils.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user