mirror of
https://github.com/Mintplex-Labs/anything-llm
synced 2026-04-25 17:15:37 +02:00
Merge branch 'master' of github.com:Mintplex-Labs/anything-llm
This commit is contained in:
2
server/.gitignore
vendored
2
server/.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
server/.env
|
||||
storage/assets/*
|
||||
!storage/assets/anything-llm.png
|
||||
!storage/assets/anything-llm-invert.png
|
||||
storage/documents/*
|
||||
storage/comkey/*
|
||||
storage/tmp/*
|
||||
@@ -11,6 +12,7 @@ storage/vector-cache/*.json
|
||||
storage/exports
|
||||
storage/imports
|
||||
storage/anythingllm-fs/*
|
||||
storage/generated-files/*
|
||||
storage/plugins/agent-skills/*
|
||||
storage/plugins/agent-flows/*
|
||||
storage/plugins/office-extensions/*
|
||||
|
||||
@@ -406,6 +406,9 @@ function adminEndpoints(app) {
|
||||
case "disabled_filesystem_skills":
|
||||
requestedSettings[label] = safeJsonParse(setting?.value, []);
|
||||
break;
|
||||
case "disabled_create_files_skills":
|
||||
requestedSettings[label] = safeJsonParse(setting?.value, []);
|
||||
break;
|
||||
case "imported_agent_skills":
|
||||
requestedSettings[label] = ImportedPlugin.listImportedPlugins();
|
||||
break;
|
||||
|
||||
142
server/endpoints/agentFileServer.js
Normal file
142
server/endpoints/agentFileServer.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const {
|
||||
userFromSession,
|
||||
multiUserMode,
|
||||
safeJsonParse,
|
||||
} = require("../utils/http");
|
||||
const { validatedRequest } = require("../utils/middleware/validatedRequest");
|
||||
const {
|
||||
flexUserRoleValid,
|
||||
ROLES,
|
||||
} = require("../utils/middleware/multiUserProtected");
|
||||
const { WorkspaceChats } = require("../models/workspaceChats");
|
||||
const { Workspace } = require("../models/workspace");
|
||||
const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib");
|
||||
|
||||
/**
|
||||
* Endpoints for serving agent-generated files (PPTX, etc.) with authentication
|
||||
* and ownership validation.
|
||||
*/
|
||||
function agentFileServerEndpoints(app) {
|
||||
if (!app) return;
|
||||
|
||||
/**
|
||||
* Download a generated file by its storage filename.
|
||||
* Validates that the requesting user has access to the workspace
|
||||
* where the file was generated.
|
||||
*/
|
||||
app.get(
|
||||
"/agent-skills/generated-files/:filename",
|
||||
[validatedRequest, flexUserRoleValid([ROLES.all])],
|
||||
async (request, response) => {
|
||||
try {
|
||||
const user = await userFromSession(request, response);
|
||||
const { filename } = request.params;
|
||||
if (!filename)
|
||||
return response.status(400).json({ error: "Filename is required" });
|
||||
|
||||
// Validate filename format
|
||||
const parsed = createFilesLib.parseFilename(filename);
|
||||
if (!parsed) {
|
||||
return response
|
||||
.status(400)
|
||||
.json({ error: "Invalid filename format" });
|
||||
}
|
||||
|
||||
// Find a chat record that references this file and that the user can access
|
||||
const validChat = await findValidChatForFile(
|
||||
filename,
|
||||
user,
|
||||
multiUserMode(response)
|
||||
);
|
||||
|
||||
if (!validChat) {
|
||||
return response.status(404).json({
|
||||
error: "File not found or access denied",
|
||||
});
|
||||
}
|
||||
|
||||
// Retrieve the file from storage
|
||||
const fileData = await createFilesLib.getGeneratedFile(filename);
|
||||
if (!fileData) {
|
||||
return response
|
||||
.status(404)
|
||||
.json({ error: "File not found in storage" });
|
||||
}
|
||||
|
||||
// Get mime type and set headers for download
|
||||
const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`);
|
||||
const safeFilename = createFilesLib.sanitizeFilenameForHeader(
|
||||
validChat.displayFilename || filename
|
||||
);
|
||||
response.setHeader("Content-Type", mimeType);
|
||||
response.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${safeFilename}"`
|
||||
);
|
||||
response.setHeader("Content-Length", fileData.buffer.length);
|
||||
return response.send(fileData.buffer);
|
||||
} catch (error) {
|
||||
console.error("[agentFileServer] Download error:", error.message);
|
||||
return response.status(500).json({ error: "Failed to download file" });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a valid chat record that references the given storage filename
|
||||
* and that the user has access to.
|
||||
* @param {string} storageFilename - The storage filename to search for
|
||||
* @param {object|null} user - The user object (null in single-user mode)
|
||||
* @param {boolean} isMultiUser - Whether multi-user mode is enabled
|
||||
* @returns {Promise<{workspaceId: number, displayFilename: string}|null>}
|
||||
*/
|
||||
async function findValidChatForFile(storageFilename, user, isMultiUser) {
|
||||
try {
|
||||
// Get all workspaces the user has access to.
|
||||
// In single-user mode, all workspaces are accessible.
|
||||
// In multi-user mode, only workspaces assigned to the user are accessible.
|
||||
let workspaceIds;
|
||||
if (isMultiUser && user) {
|
||||
const workspaces = await Workspace.whereWithUser(user);
|
||||
workspaceIds = workspaces.map((w) => w.id);
|
||||
} else {
|
||||
const workspaces = await Workspace.where();
|
||||
workspaceIds = workspaces.map((w) => w.id);
|
||||
}
|
||||
|
||||
if (workspaceIds.length === 0) return null;
|
||||
|
||||
// Use database-level filtering to only fetch chats that contain the filename
|
||||
// This avoids loading all chats into memory
|
||||
const chats = await WorkspaceChats.where({
|
||||
workspaceId: { in: workspaceIds },
|
||||
include: true,
|
||||
response: { contains: storageFilename },
|
||||
});
|
||||
|
||||
for (const chat of chats) {
|
||||
try {
|
||||
const response = safeJsonParse(chat.response, { outputs: [] });
|
||||
const output = response.outputs.find(
|
||||
(o) => o?.payload?.storageFilename === storageFilename
|
||||
);
|
||||
if (!output) continue;
|
||||
return {
|
||||
workspaceId: chat.workspaceId,
|
||||
displayFilename:
|
||||
output.payload.filename || output.payload.displayFilename,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[findValidChatForFile] Error:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { agentFileServerEndpoints };
|
||||
@@ -27,6 +27,24 @@ function agentSkillWhitelistEndpoints(app) {
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/agent-skills/create-files-agent/is-available",
|
||||
[validatedRequest],
|
||||
async (_request, response) => {
|
||||
try {
|
||||
const createFilesTool = require("../utils/agents/aibitat/plugins/create-files/lib");
|
||||
return response
|
||||
.status(200)
|
||||
.json({ available: createFilesTool.isToolAvailable() });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return response
|
||||
.status(500)
|
||||
.json({ available: false, error: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/agent-skills/whitelist/add",
|
||||
[validatedRequest, flexUserRoleValid(ROLES.all)],
|
||||
|
||||
@@ -210,4 +210,5 @@ module.exports = {
|
||||
getGitVersion,
|
||||
getModelTag,
|
||||
getAnythingLLMUserAgent,
|
||||
getDeploymentVersion,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ const { agentWebsocket } = require("./endpoints/agentWebsocket");
|
||||
const {
|
||||
agentSkillWhitelistEndpoints,
|
||||
} = require("./endpoints/agentSkillWhitelist");
|
||||
const { agentFileServerEndpoints } = require("./endpoints/agentFileServer");
|
||||
const { experimentalEndpoints } = require("./endpoints/experimental");
|
||||
const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
|
||||
const { communityHubEndpoints } = require("./endpoints/communityHub");
|
||||
@@ -79,6 +80,7 @@ utilEndpoints(apiRouter);
|
||||
documentEndpoints(apiRouter);
|
||||
agentWebsocket(apiRouter);
|
||||
agentSkillWhitelistEndpoints(apiRouter);
|
||||
agentFileServerEndpoints(apiRouter);
|
||||
experimentalEndpoints(apiRouter);
|
||||
developerEndpoints(app, apiRouter);
|
||||
communityHubEndpoints(apiRouter);
|
||||
|
||||
108
server/jobs/cleanup-generated-files.js
Normal file
108
server/jobs/cleanup-generated-files.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const { log, conclude } = require("./helpers/index.js");
|
||||
const { WorkspaceChats } = require("../models/workspaceChats.js");
|
||||
const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib.js");
|
||||
const { safeJsonParse } = require("../utils/http/index.js");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const storageDirectory = await createFilesLib.getOutputDirectory();
|
||||
if (!fs.existsSync(storageDirectory)) return;
|
||||
|
||||
const files = fs.readdirSync(storageDirectory);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Get all storage filenames referenced in active (include: true) chats
|
||||
const activeFileRefs = await getActiveStorageFilenames();
|
||||
const filesToDelete = [];
|
||||
for (const filename of files) {
|
||||
const fullPath = path.join(storageDirectory, filename);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
// Skip files/folders that don't match our naming pattern and add to deletion list
|
||||
if (!filename.match(/^[a-z]+-[a-f0-9-]{36}(\.\w+)?$/i)) {
|
||||
filesToDelete.push({ path: fullPath, isDirectory: stat.isDirectory() });
|
||||
continue;
|
||||
}
|
||||
|
||||
// If file/folder is not referenced in any active chat, add to deletion list
|
||||
if (!activeFileRefs.has(filename))
|
||||
filesToDelete.push({ path: fullPath, isDirectory: stat.isDirectory() });
|
||||
}
|
||||
|
||||
if (filesToDelete.length === 0) return;
|
||||
|
||||
log(`Found ${filesToDelete.length} orphaned files/folders to delete.`);
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
for (const { path: itemPath, isDirectory } of filesToDelete) {
|
||||
try {
|
||||
if (isDirectory) fs.rmSync(itemPath, { recursive: true });
|
||||
else fs.unlinkSync(itemPath);
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
log(`Failed to delete ${itemPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
`Cleanup complete: deleted ${deletedCount} items, ${failedCount} failures.`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
log(`Error during cleanup: ${error.message}`);
|
||||
} finally {
|
||||
conclude();
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Retrieves all storage filenames referenced in active (include: true) workspace chats.
|
||||
* Searches through the outputs array in chat responses.
|
||||
* Uses pagination to avoid loading all chats into memory at once.
|
||||
* @param {number} batchSize - Number of chats to process per batch (default: 50)
|
||||
* @returns {Promise<Set<string>>}
|
||||
*/
|
||||
async function getActiveStorageFilenames(batchSize = 50) {
|
||||
const storageFilenames = new Set();
|
||||
|
||||
try {
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const chats = await WorkspaceChats.where(
|
||||
{ include: true },
|
||||
batchSize,
|
||||
{ id: "asc" },
|
||||
offset
|
||||
);
|
||||
|
||||
if (chats.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const chat of chats) {
|
||||
try {
|
||||
const response = safeJsonParse(chat.response, { outputs: [] });
|
||||
for (const output of response.outputs) {
|
||||
if (output?.payload?.storageFilename)
|
||||
storageFilenames.add(output.payload.storageFilename);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
offset += chats.length;
|
||||
hasMore = chats.length === batchSize;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[getActiveStorageFilenames] Error:", error.message);
|
||||
}
|
||||
|
||||
return storageFilenames;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ const SystemSettings = {
|
||||
"default_agent_skills",
|
||||
"disabled_agent_skills",
|
||||
"disabled_filesystem_skills",
|
||||
"disabled_create_files_skills",
|
||||
"imported_agent_skills",
|
||||
"custom_app_name",
|
||||
"feature_flags",
|
||||
@@ -52,6 +53,7 @@ const SystemSettings = {
|
||||
"default_agent_skills",
|
||||
"disabled_agent_skills",
|
||||
"disabled_filesystem_skills",
|
||||
"disabled_create_files_skills",
|
||||
"agent_sql_connections",
|
||||
"custom_app_name",
|
||||
"default_system_prompt",
|
||||
@@ -163,6 +165,15 @@ const SystemSettings = {
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
},
|
||||
disabled_create_files_skills: (updates) => {
|
||||
try {
|
||||
const skills = updates.split(",").filter((skill) => !!skill);
|
||||
return JSON.stringify(skills);
|
||||
} catch {
|
||||
console.error(`Could not validate disabled create files skills.`);
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
},
|
||||
agent_sql_connections: async (updates) => {
|
||||
const existingConnections = safeJsonParse(
|
||||
(await SystemSettings.get({ label: "agent_sql_connections" }))?.value,
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@langchain/core": "0.1.61",
|
||||
"@langchain/openai": "0.0.28",
|
||||
"@langchain/textsplitters": "0.0.0",
|
||||
"@mdpdf/mdpdf": "0.1.4",
|
||||
"@mintplex-labs/bree": "^9.2.5",
|
||||
"@mintplex-labs/express-ws": "^5.0.7",
|
||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||
@@ -54,8 +55,11 @@
|
||||
"cohere-ai": "^7.19.0",
|
||||
"cors": "^2.8.5",
|
||||
"diff": "7.0.0",
|
||||
"docx": "9.6.1",
|
||||
"dompurify": "3.3.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"elevenlabs": "^0.5.0",
|
||||
"exceljs": "4.4.0",
|
||||
"express": "^4.21.2",
|
||||
"extract-json-from-string": "^1.0.1",
|
||||
"fast-levenshtein": "^3.0.0",
|
||||
@@ -67,7 +71,9 @@
|
||||
"js-tiktoken": "^1.0.8",
|
||||
"jsonrepair": "^3.7.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"langchain": "0.1.36",
|
||||
"marked": "15.0.12",
|
||||
"mime": "^3.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"mssql": "^10.0.2",
|
||||
@@ -76,10 +82,12 @@
|
||||
"node-telegram-bot-api": "^0.67.0",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "4.95.1",
|
||||
"pdf-lib": "1.17.1",
|
||||
"pg": "^8.11.5",
|
||||
"pinecone-client": "^1.1.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-node": "^3.1.1",
|
||||
"pptxgenjs": "4.0.1",
|
||||
"prisma": "5.3.1",
|
||||
"slugify": "^1.6.6",
|
||||
"strip-ansi": "^7.1.2",
|
||||
|
||||
BIN
server/storage/assets/anything-llm-invert.png
Normal file
BIN
server/storage/assets/anything-llm-invert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -15,6 +15,11 @@ class BackgroundService {
|
||||
timeout: "1m",
|
||||
interval: "12hr",
|
||||
},
|
||||
{
|
||||
name: "cleanup-generated-files",
|
||||
timeout: "5m",
|
||||
interval: "8hr",
|
||||
},
|
||||
];
|
||||
|
||||
#documentSyncJobs = [
|
||||
|
||||
@@ -53,6 +53,7 @@ const chatHistory = {
|
||||
const invocation = aibitat.handlerProps.invocation;
|
||||
const metrics = aibitat.provider?.getUsage?.() ?? {};
|
||||
const citations = aibitat._pendingCitations ?? [];
|
||||
const outputs = aibitat._pendingOutputs ?? [];
|
||||
await WorkspaceChats.new({
|
||||
workspaceId: Number(invocation.workspace_id),
|
||||
prompt,
|
||||
@@ -62,11 +63,13 @@ const chatHistory = {
|
||||
type: "chat",
|
||||
attachments,
|
||||
metrics,
|
||||
...(outputs.length > 0 ? { outputs } : {}),
|
||||
},
|
||||
user: { id: invocation?.user_id || null },
|
||||
threadId: invocation?.thread_id || null,
|
||||
});
|
||||
aibitat.clearCitations?.();
|
||||
aibitat._pendingOutputs = [];
|
||||
},
|
||||
_storeSpecial: async function (
|
||||
aibitat,
|
||||
@@ -75,6 +78,7 @@ const chatHistory = {
|
||||
const invocation = aibitat.handlerProps.invocation;
|
||||
const metrics = aibitat.provider?.getUsage?.() ?? {};
|
||||
const citations = aibitat._pendingCitations ?? [];
|
||||
const outputs = aibitat._pendingOutputs ?? [];
|
||||
const existingSources = options?.sources ?? [];
|
||||
await WorkspaceChats.new({
|
||||
workspaceId: Number(invocation.workspace_id),
|
||||
@@ -89,11 +93,13 @@ const chatHistory = {
|
||||
type: options?.saveAsType ?? "chat",
|
||||
attachments,
|
||||
metrics,
|
||||
...(outputs.length > 0 ? { outputs } : {}),
|
||||
},
|
||||
user: { id: invocation?.user_id || null },
|
||||
threadId: invocation?.thread_id || null,
|
||||
});
|
||||
aibitat.clearCitations?.();
|
||||
aibitat._pendingOutputs = [];
|
||||
options?.postSave();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
const { FilesystemReadTextFile } = require("./read-text-file.js");
|
||||
const { FilesystemReadMultipleFiles } = require("./read-multiple-files.js");
|
||||
const { FilesystemWriteFile } = require("./write-file.js");
|
||||
const { FilesystemWriteTextFile } = require("./write-text-file.js");
|
||||
const { FilesystemEditFile } = require("./edit-file.js");
|
||||
const { FilesystemCreateDirectory } = require("./create-directory.js");
|
||||
const { FilesystemListDirectory } = require("./list-directory.js");
|
||||
@@ -17,7 +17,7 @@ const filesystemAgent = {
|
||||
plugin: [
|
||||
FilesystemReadTextFile,
|
||||
FilesystemReadMultipleFiles,
|
||||
FilesystemWriteFile,
|
||||
FilesystemWriteTextFile,
|
||||
FilesystemEditFile,
|
||||
FilesystemCreateDirectory,
|
||||
FilesystemListDirectory,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
const filesystem = require("./lib.js");
|
||||
|
||||
module.exports.FilesystemWriteFile = {
|
||||
name: "filesystem-write-file",
|
||||
module.exports.FilesystemWriteTextFile = {
|
||||
name: "filesystem-write-text-file",
|
||||
plugin: function () {
|
||||
return {
|
||||
name: "filesystem-write-file",
|
||||
name: "filesystem-write-text-file",
|
||||
setup(aibitat) {
|
||||
aibitat.function({
|
||||
super: aibitat,
|
||||
name: this.name,
|
||||
description:
|
||||
"Create a new file or completely overwrite an existing file with new content. " +
|
||||
"Create a new text file or completely overwrite an existing text file with new content. " +
|
||||
"Use with caution as it will overwrite existing files without warning. " +
|
||||
"Handles text content with proper encoding. Only works within allowed directories.",
|
||||
"Only handles text/plaintext content with proper encoding. Only works within allowed directories. " +
|
||||
"For binary formats (PDF, DOCX, XLSX, PPTX), use the appropriate document creation tools instead.",
|
||||
examples: [
|
||||
{
|
||||
prompt: "Create a new config file with these settings",
|
||||
@@ -49,7 +50,7 @@ module.exports.FilesystemWriteFile = {
|
||||
handler: async function ({ path: filePath = "", content = "" }) {
|
||||
try {
|
||||
this.super.handlerProps.log(
|
||||
`Using the filesystem-write-file tool.`
|
||||
`Using the filesystem-write-text-file tool.`
|
||||
);
|
||||
|
||||
const validPath = await filesystem.validatePath(filePath);
|
||||
@@ -76,7 +77,7 @@ module.exports.FilesystemWriteFile = {
|
||||
return `Successfully wrote to ${filePath}`;
|
||||
} catch (e) {
|
||||
this.super.handlerProps.log(
|
||||
`filesystem-write-file error: ${e.message}`
|
||||
`filesystem-write-text-file error: ${e.message}`
|
||||
);
|
||||
this.super.introspect(`Error: ${e.message}`);
|
||||
return `Error writing file: ${e.message}`;
|
||||
@@ -2,34 +2,34 @@ const { webBrowsing } = require("./web-browsing.js");
|
||||
const { webScraping } = require("./web-scraping.js");
|
||||
const { websocket } = require("./websocket.js");
|
||||
const { docSummarizer } = require("./summarize.js");
|
||||
const { saveFileInBrowser } = require("./save-file-browser.js");
|
||||
const { chatHistory } = require("./chat-history.js");
|
||||
const { memory } = require("./memory.js");
|
||||
const { rechart } = require("./rechart.js");
|
||||
const { sqlAgent } = require("./sql-agent/index.js");
|
||||
const { filesystemAgent } = require("./filesystem/index.js");
|
||||
const { createFilesAgent } = require("./create-files/index.js");
|
||||
|
||||
module.exports = {
|
||||
webScraping,
|
||||
webBrowsing,
|
||||
websocket,
|
||||
docSummarizer,
|
||||
saveFileInBrowser,
|
||||
chatHistory,
|
||||
memory,
|
||||
rechart,
|
||||
sqlAgent,
|
||||
filesystemAgent,
|
||||
createFilesAgent,
|
||||
|
||||
// Plugin name aliases so they can be pulled by slug as well.
|
||||
[webScraping.name]: webScraping,
|
||||
[webBrowsing.name]: webBrowsing,
|
||||
[websocket.name]: websocket,
|
||||
[docSummarizer.name]: docSummarizer,
|
||||
[saveFileInBrowser.name]: saveFileInBrowser,
|
||||
[chatHistory.name]: chatHistory,
|
||||
[memory.name]: memory,
|
||||
[rechart.name]: rechart,
|
||||
[sqlAgent.name]: sqlAgent,
|
||||
[filesystemAgent.name]: filesystemAgent,
|
||||
[createFilesAgent.name]: createFilesAgent,
|
||||
};
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
const { Deduplicator } = require("../utils/dedupe");
|
||||
|
||||
const saveFileInBrowser = {
|
||||
name: "save-file-to-browser",
|
||||
startupConfig: {
|
||||
params: {},
|
||||
},
|
||||
plugin: function () {
|
||||
return {
|
||||
name: this.name,
|
||||
setup(aibitat) {
|
||||
// List and summarize the contents of files that are embedded in the workspace
|
||||
aibitat.function({
|
||||
super: aibitat,
|
||||
tracker: new Deduplicator(),
|
||||
name: this.name,
|
||||
description:
|
||||
"Download or export content as a file. Save text, code, data, or conversation content to a downloadable file. Use when the user wants to save, download, or export something as a file.",
|
||||
examples: [
|
||||
{
|
||||
prompt: "Download that as a file",
|
||||
call: JSON.stringify({
|
||||
file_content: "<content to save>",
|
||||
filename: "download.txt",
|
||||
}),
|
||||
},
|
||||
{
|
||||
prompt: "Save that code to a file",
|
||||
call: JSON.stringify({
|
||||
file_content: "<code content>",
|
||||
filename: "code.js",
|
||||
}),
|
||||
},
|
||||
{
|
||||
prompt: "Save me that to a file named 'output'",
|
||||
call: JSON.stringify({
|
||||
file_content: "<content of the file>",
|
||||
filename: "output.txt",
|
||||
}),
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "object",
|
||||
properties: {
|
||||
file_content: {
|
||||
type: "string",
|
||||
description: "The content of the file that will be saved.",
|
||||
},
|
||||
filename: {
|
||||
type: "string",
|
||||
description:
|
||||
"filename to save the file as with extension. Extension should be plaintext file extension.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async function ({ file_content = "", filename }) {
|
||||
try {
|
||||
const { isDuplicate, reason } = this.tracker.isDuplicate(
|
||||
this.name,
|
||||
{ file_content, filename }
|
||||
);
|
||||
if (isDuplicate) {
|
||||
this.super.handlerProps.log(
|
||||
`${this.name} was called, but exited early because ${reason}.`
|
||||
);
|
||||
return `${filename} file has been saved successfully!`;
|
||||
}
|
||||
|
||||
this.super.socket.send("fileDownload", {
|
||||
filename,
|
||||
b64Content:
|
||||
"data:text/plain;base64," +
|
||||
Buffer.from(file_content, "utf8").toString("base64"),
|
||||
});
|
||||
this.super.introspect(`${this.caller}: Saving file ${filename}.`);
|
||||
this.tracker.trackRun(this.name, { file_content, filename });
|
||||
return `${filename} file has been saved successfully and will be downloaded automatically to the users browser.`;
|
||||
} catch (error) {
|
||||
this.super.handlerProps.log(
|
||||
`save-file-to-browser raised an error. ${error.message}`
|
||||
);
|
||||
return `Let the user know this action was not successful. An error was raised while saving a file to the browser. ${error.message}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
saveFileInBrowser,
|
||||
};
|
||||
@@ -81,6 +81,15 @@ async function agentSkillsFromSystemSettings() {
|
||||
[]
|
||||
);
|
||||
|
||||
// Load disabled create-files sub-skills
|
||||
const _disabledCreateFilesSkills = safeJsonParse(
|
||||
await SystemSettings.getValueOrFallback(
|
||||
{ label: "disabled_create_files_skills" },
|
||||
"[]"
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// Load non-imported built-in skills that are configurable.
|
||||
const _setting = safeJsonParse(
|
||||
await SystemSettings.getValueOrFallback(
|
||||
@@ -106,6 +115,16 @@ async function agentSkillsFromSystemSettings() {
|
||||
if (_disabledFilesystemSkills.includes(subPlugin.name)) continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the create-files tool is not available, or the sub-skill is explicitly disabled, skip it
|
||||
* This is a docker specific skill so it cannot be used in other environments.
|
||||
*/
|
||||
if (skillName === "create-files-agent") {
|
||||
const createFilesTool = require("./aibitat/plugins/create-files/lib");
|
||||
if (!createFilesTool.isToolAvailable()) continue;
|
||||
if (_disabledCreateFilesSkills.includes(subPlugin.name)) continue;
|
||||
}
|
||||
|
||||
systemFunctions.push(
|
||||
`${AgentPlugins[skillName].name}#${subPlugin.name}`
|
||||
);
|
||||
|
||||
@@ -157,6 +157,7 @@ function convertToChatHistory(history = []) {
|
||||
sentAt: moment(createdAt).unix(),
|
||||
feedbackScore,
|
||||
metrics: data?.metrics || {},
|
||||
...(data?.outputs?.length > 0 ? { outputs: data.outputs } : {}),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ async function handleAgentResponse(
|
||||
const sources = [];
|
||||
const thoughts = [];
|
||||
const charts = [];
|
||||
const files = [];
|
||||
const _files = [];
|
||||
let thoughtMsgId = null;
|
||||
let lastThoughtText = "";
|
||||
|
||||
@@ -130,8 +130,8 @@ async function handleAgentResponse(
|
||||
case "rechartVisualize":
|
||||
if (parsed.content) charts.push(parsed.content);
|
||||
return;
|
||||
case "fileDownload":
|
||||
if (parsed.content) files.push(parsed.content);
|
||||
case "fileDownloadCard":
|
||||
if (parsed.content) _files.push(parsed.content);
|
||||
return;
|
||||
case "reportStreamEvent":
|
||||
const inner = parsed.content;
|
||||
@@ -170,8 +170,16 @@ async function handleAgentResponse(
|
||||
? "✓ <b>Agent completed:</b>"
|
||||
: "🤔 <b>Agent is thinking:</b>";
|
||||
const icon = done ? "✓" : "⏳";
|
||||
const maxThoughtLen = 100;
|
||||
const content = thoughtList
|
||||
.map((t) => `${icon} ${escapeHTML(t)}`)
|
||||
.map((t) => {
|
||||
const escaped = escapeHTML(t);
|
||||
const truncated =
|
||||
escaped.length > maxThoughtLen
|
||||
? escaped.slice(0, maxThoughtLen) + "..."
|
||||
: escaped;
|
||||
return `${icon} ${truncated}`;
|
||||
})
|
||||
.join("\n");
|
||||
const fullContent = `${header}\n${content}`;
|
||||
const tag =
|
||||
@@ -223,8 +231,9 @@ async function handleAgentResponse(
|
||||
ctx.bot.sendChatAction(chatId, "typing").catch(() => {});
|
||||
}, 4000);
|
||||
|
||||
let agentHandler = null;
|
||||
try {
|
||||
const agentHandler = await new EphemeralAgentHandler({
|
||||
agentHandler = await new EphemeralAgentHandler({
|
||||
uuid: uuidv4(),
|
||||
workspace,
|
||||
prompt: message,
|
||||
@@ -239,101 +248,140 @@ async function handleAgentResponse(
|
||||
agentHandler.aibitat.maxRounds = 2;
|
||||
|
||||
await agentHandler.startAgentCluster();
|
||||
|
||||
// Extract pending outputs from aibitat for persistence
|
||||
const outputs = agentHandler?.aibitat?._pendingOutputs ?? [];
|
||||
|
||||
// Final thought update, mark as completed
|
||||
if (thoughtMsgId && thoughts.length > 0) {
|
||||
const doneText = formatThoughtsAsBlockquote(thoughts, true);
|
||||
await editMessage(ctx.bot, chatId, thoughtMsgId, doneText, ctx.log, {
|
||||
html: true,
|
||||
disableLinkPreview: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Send charts as locally rendered images
|
||||
for (const chart of charts) {
|
||||
try {
|
||||
const buffer = await renderChartToBuffer(chart);
|
||||
await ctx.bot.sendPhoto(
|
||||
chatId,
|
||||
buffer,
|
||||
{ caption: chart.title },
|
||||
{
|
||||
filename: "chart.png",
|
||||
contentType: "image/png",
|
||||
knownLength: buffer.length,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
await ctx.bot.sendMessage(
|
||||
chatId,
|
||||
`${chart.title}: failed to render chart.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the initial sendMessage has resolved before deciding how to deliver
|
||||
if (responsePending) await responsePending;
|
||||
|
||||
// Fall back to the accumulated streamed text when no explicit
|
||||
// fullTextResponse event was received (e.g. audio/voice messages).
|
||||
const responseText = finalResponse || streamingText;
|
||||
|
||||
if (responseText) {
|
||||
await WorkspaceChats.new({
|
||||
workspaceId: workspace.id,
|
||||
prompt: message,
|
||||
response: {
|
||||
text: responseText,
|
||||
sources,
|
||||
type: "chat",
|
||||
metrics,
|
||||
attachments,
|
||||
...(outputs.length > 0 ? { outputs } : {}),
|
||||
},
|
||||
threadId: thread?.id || null,
|
||||
});
|
||||
|
||||
// Always deliver text response first
|
||||
if (responseMsgId) {
|
||||
await editMessage(
|
||||
ctx.bot,
|
||||
chatId,
|
||||
responseMsgId,
|
||||
finalResponse || currentResponseText(),
|
||||
ctx.log,
|
||||
{
|
||||
format: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
} else {
|
||||
await sendFormattedMessage(ctx.bot, chatId, responseText);
|
||||
}
|
||||
|
||||
// Send voice as an additional attachment if requested
|
||||
if (voiceResponse) {
|
||||
ctx.log?.info?.(`Generating voice response for ${chatId}`);
|
||||
await sendVoiceResponse(ctx.bot, chatId, responseText);
|
||||
}
|
||||
}
|
||||
|
||||
// Send files as Telegram documents (after response is delivered)
|
||||
if (_files.length > 0) {
|
||||
ctx.log?.info?.(`Sending ${_files.length} file(s) to Telegram`);
|
||||
await sendFilesAsTelegramDocuments(ctx, chatId, _files);
|
||||
}
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
clearTimeout(thoughtFlushTimeout);
|
||||
clearTimeout(editTimer);
|
||||
}
|
||||
}
|
||||
|
||||
// Final thought update, mark as completed
|
||||
if (thoughtMsgId && thoughts.length > 0) {
|
||||
const doneText = formatThoughtsAsBlockquote(thoughts, true);
|
||||
await editMessage(ctx.bot, chatId, thoughtMsgId, doneText, ctx.log, {
|
||||
html: true,
|
||||
disableLinkPreview: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Send charts as locally rendered images
|
||||
for (const chart of charts) {
|
||||
try {
|
||||
const buffer = await renderChartToBuffer(chart);
|
||||
await ctx.bot.sendPhoto(
|
||||
chatId,
|
||||
buffer,
|
||||
{ caption: chart.title },
|
||||
{
|
||||
filename: "chart.png",
|
||||
contentType: "image/png",
|
||||
knownLength: buffer.length,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
await ctx.bot.sendMessage(
|
||||
chatId,
|
||||
`${chart.title}: failed to render chart.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send files as Telegram documents
|
||||
/**
|
||||
* Send generated files as Telegram documents without blocking the main response.
|
||||
* @param {import("../commands").BotContext} ctx
|
||||
* @param {number} chatId
|
||||
* @param {Array<{filename: string, storageFilename: string, fileSize: number}>} files
|
||||
*/
|
||||
async function sendFilesAsTelegramDocuments(ctx, chatId, files) {
|
||||
const createFilesLib = require("../../agents/aibitat/plugins/create-files/lib");
|
||||
for (const file of files) {
|
||||
try {
|
||||
const base64Data = file.b64Content.split(",")[1];
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
ctx.log?.info?.(`Retrieving file: ${file.storageFilename}`);
|
||||
const result = await createFilesLib.getGeneratedFile(
|
||||
file.storageFilename
|
||||
);
|
||||
if (!result?.buffer) {
|
||||
ctx.log?.warn?.(
|
||||
`Could not retrieve generated file: ${file.storageFilename}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const extension = file.storageFilename.split(".").pop() || "";
|
||||
const mimeType = createFilesLib.getMimeType(extension);
|
||||
|
||||
ctx.log?.info?.(
|
||||
`Sending document: ${file.filename} (${result.buffer.length} bytes, ${mimeType})`
|
||||
);
|
||||
await ctx.bot.sendDocument(
|
||||
chatId,
|
||||
buffer,
|
||||
{},
|
||||
result.buffer,
|
||||
{ caption: file.filename },
|
||||
{
|
||||
filename: file.filename,
|
||||
contentType: "application/octet-stream",
|
||||
contentType: mimeType,
|
||||
}
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Ensure the initial sendMessage has resolved before deciding how to deliver
|
||||
if (responsePending) await responsePending;
|
||||
|
||||
// Fall back to the accumulated streamed text when no explicit
|
||||
// fullTextResponse event was received (e.g. audio/voice messages).
|
||||
const responseText = finalResponse || streamingText;
|
||||
|
||||
if (responseText) {
|
||||
await WorkspaceChats.new({
|
||||
workspaceId: workspace.id,
|
||||
prompt: message,
|
||||
response: {
|
||||
text: responseText,
|
||||
sources,
|
||||
type: "chat",
|
||||
metrics,
|
||||
attachments,
|
||||
},
|
||||
threadId: thread?.id || null,
|
||||
});
|
||||
|
||||
// Always deliver text response first
|
||||
if (responseMsgId) {
|
||||
await editMessage(
|
||||
ctx.bot,
|
||||
chatId,
|
||||
responseMsgId,
|
||||
finalResponse || currentResponseText(),
|
||||
ctx.log,
|
||||
{
|
||||
format: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
} else {
|
||||
await sendFormattedMessage(ctx.bot, chatId, responseText);
|
||||
}
|
||||
|
||||
// Send voice as an additional attachment if requested
|
||||
if (voiceResponse) {
|
||||
ctx.log?.info?.(`Generating voice response for ${chatId}`);
|
||||
await sendVoiceResponse(ctx.bot, chatId, responseText);
|
||||
ctx.log?.info?.(`Successfully sent document: ${file.filename}`);
|
||||
} catch (err) {
|
||||
ctx.log?.error?.(
|
||||
`Failed to send document ${file.filename}:`,
|
||||
err.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1597
server/yarn.lock
1597
server/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user