Forked from acmar/export-to-word-plugin
src / toolsProvider.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { writeFile, readFile } from "fs/promises";
import { join } from "path";
import { z } from "zod";
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from "docx";
import AdmZip from "adm-zip"; // library for working with ZIP archives
const chatMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
timestamp: z.number().optional().describe("Optional"),
});
export async function toolsProvider(ctl: ToolsProviderController) {
const tools: Tool[] = [];
// Export tool (creates a file from scratch) #1
const exportWordTool = tool({
name: "export_to_docx",
description: text`
Exports the current chat conversation to a Word (.docx) file.
Arrays of messages where each message includes the role (user/assistant/system) and content.
You must receive the full list of messages from the user to export.
---
Returns the path where the file was saved. Its name is optional.
`,
parameters: {
messages: z.array(chatMessageSchema).describe("Array of chat messages to export"),
filename: z.string().optional().describe("Optional custom filename (without extension)"),
},
implementation: async ({ messages, filename }) => {
const workingDirectory = ctl.getWorkingDirectory();
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, "-");
const outputFilename = (filename ?? `chat-export-${timestamp}`) + ".docx";
const outputPath = join(workingDirectory, outputFilename);
const children: Paragraph[] = [];
// Header
children.push(
new Paragraph({
text: "EXPORT CHAT -- LM STUDIO",
heading: HeadingLevel.HEADING_1,
}),
new Paragraph({
children: [new TextRun(`Date: ${now.toLocaleString("ru")}`)],
}),
new Paragraph({
children: [new TextRun(`size: ${messages.length}`)],
}),
new Paragraph({ text: "" }), // Empty paragraph for spacing
);
// Loop through all messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
// Guard against errors if some array element is unexpectedly "empty"
if (!msg || !msg.content) continue;
const role =
msg.role === "user" ? "USER" :
msg.role === "assistant" ? "ASSISTANT" :
msg.role === "system" ? "SYSTEM" : "MIXED"; // Fallback in case the role doesn't match any known value
const time = msg.timestamp
? new Date(msg.timestamp).toLocaleTimeString("ru")
: "--:--";
// Build message label
const headerText = `[${i + 1}] ${role} (${time})`;
children.push(
new Paragraph({
children: [new TextRun({ text: headerText, bold: true })],
}),
new Paragraph({
// Key: use children with breaks enabled for multiline content
children: [
new TextRun({
text: msg.content,
break: 1, // allow line breaks within the text
}),
],
}),
new Paragraph({ text: "" }), // Spacing after message
);
}
const doc = new Document({ sections: [{ children }] });
const buffer = await Packer.toBuffer(doc);
await writeFile(outputPath, buffer);
return { success: true, path: outputPath };
},
});
// Tool #2 Appending to an existing file...
const appendParagraphTool = tool({
name: "append_paragraph",
description: text`
Appends a new paragraph to an existing Word (.docx) document.
It reads the current file specified by 'filename', adds a paragraph at the end, and saves it back.
Useful for adding notes or metadata without rewriting the whole document.
---
Returns the path where the updated file was saved.
`,
parameters: {
filename: z.string().describe("Name of the existing file to append to. Must include extension (e.g., 'report.docx')."),
text: z.string().describe("The text content to add in the new paragraph."),
},
implementation: async ({ filename, text }) => {
const workingDirectory = ctl.getWorkingDirectory();
const inputPath = join(workingDirectory, filename);
// Check if file exists before opening ZIP
try {
await readFile(inputPath, "utf8");
} catch (err) {
throw new Error("File not found or inaccessible: " + inputPath);
}
// 1. Open .docx as ZIP archive
let zip: AdmZip;
try {
zip = new AdmZip(inputPath);
} catch (err) {
throw new Error("Failed to open .docx as ZIP: " + String(err));
}
// 2. Read word/document.xml
let xmlContent: string;
try {
xmlContent = zip.readAsText("word/document.xml");
} catch (err) {
throw new Error("Failed to read word/document.xml: " + String(err));
}
// 3. Search for end tag and insert new paragraph
const bodyEndTag = "</w:body>";
const bodyIndex = xmlContent.indexOf(bodyEndTag);
if (bodyIndex === -1) {
throw new Error("Cannot find </w:body> in document.xml at position " + bodyIndex);
}
// 4. Generate new paragraph XML
const newParagraph = `
<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:r>
<w:t xml:space="preserve">${text}</w:t>
</w:r>
</w:p>`;
// 5. Insert paragraph before end of <w:body>
const updatedXml =
xmlContent.substring(0, bodyIndex) +
newParagraph +
xmlContent.substring(bodyIndex);
// 6. Update file in ZIP archive
try {
zip.addFile("word/document.xml", Buffer.from(updatedXml));
} catch (err) {
throw new Error("Failed to update document.xml in ZIP: " + String(err));
}
// 7. Save changes
let outputBuffer: Buffer;
try {
outputBuffer = zip.toBuffer();
} catch (err) {
throw new Error("Failed to create ZIP buffer: " + String(err));
}
try {
await writeFile(inputPath, outputBuffer);
} catch (err) {
throw new Error("Failed to write updated document: " + String(err));
}
return { success: true, path: inputPath };
},
});
tools.push(exportWordTool);
tools.push(appendParagraphTool);
return tools;
}
//end.