From fe71a15437e72eddf4e96dcf84d1a03f345029d1 Mon Sep 17 00:00:00 2001 From: erise133 Date: Mon, 16 Dec 2024 17:44:48 +0100 Subject: [PATCH] feat: add support for handlebars templating engine as an option --- packages/core/package.json | 1 + packages/core/src/context.ts | 48 +++++- packages/core/src/tests/context.test.ts | 198 ++++++++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/tests/context.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 7b20169e41..85afc49c88 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -64,6 +64,7 @@ "fastestsmallesttextencoderdecoder": "1.0.22", "gaxios": "6.7.1", "glob": "11.0.0", + "handlebars": "^4.7.8", "js-sha1": "0.7.0", "js-tiktoken": "1.0.15", "langchain": "0.3.6", diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index e247210a38..1a45646750 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,3 +1,4 @@ +import handlebars from "handlebars"; import { type State } from "./types.ts"; /** @@ -7,9 +8,12 @@ import { type State } from "./types.ts"; * It replaces each placeholder with the value from the state object that matches the placeholder's name. * If a matching key is not found in the state object for a given placeholder, the placeholder is replaced with an empty string. * + * By default, this function uses a simple string replacement approach. However, when `templatingEngine` is set to `'handlebars'`, it uses Handlebars templating engine instead, compiling the template into a reusable function and evaluating it with the provided state object. + * * @param {Object} params - The parameters for composing the context. * @param {State} params.state - The state object containing values to replace the placeholders in the template. * @param {string} params.template - The template string containing placeholders to be replaced with state values. + * @param {"handlebars" | undefined} [params.templatingEngine] - The templating engine to use for compiling and evaluating the template (optional, default: `undefined`). * @returns {string} The composed context string with placeholders replaced by corresponding state values. * * @example @@ -17,17 +21,57 @@ import { type State } from "./types.ts"; * const state = { userName: "Alice", userAge: 30 }; * const template = "Hello, {{userName}}! You are {{userAge}} years old"; * - * // Composing the context will result in: + * // Composing the context with simple string replacement will result in: * // "Hello, Alice! You are 30 years old." - * const context = composeContext({ state, template }); + * const contextSimple = composeContext({ state, template }); + * + * // Composing the context with Handlebars templating engine will also produce the same output. + * const contextHandlebars = composeContext({ state, template, templatingEngine: 'handlebars' }); + * + * // Handlebars provides more advanced features than simple string replacement. + * // For example, you can use conditionals or loops right in your template: + * const advancedTemplate = ` + * {{#if userAge}} + * Hello, {{userName}}! {{#if (gt userAge 18)}}You are an adult.{{else}}You are a minor.{{/if}} + * {{else}} + * Hello! We don't know your age. + * {{/if}} + * + * {{#each favoriteColors}} + * - Your favorite color: {{this}} + * {{/each}} + * `; + * + * const advancedState = { + * userName: "Alice", + * userAge: 30, + * favoriteColors: ["blue", "green", "red"] + * }; + * + * // Using Handlebars with the more advanced template: + * const advancedContextHandlebars = composeContext({ state: advancedState, template: advancedTemplate, templatingEngine: 'handlebars' }); + * + * // The above will produce: + * // "Hello, Alice! You are an adult. + * // + * // - Your favorite color: blue + * // - Your favorite color: green + * // - Your favorite color: red" */ export const composeContext = ({ state, template, + templatingEngine, }: { state: State; template: string; + templatingEngine?: "handlebars"; }) => { + if (templatingEngine === "handlebars") { + const templateFunction = handlebars.compile(template); + return templateFunction(state); + } + // @ts-expect-error match isn't working as expected const out = template.replace(/{{\w+}}/g, (match) => { const key = match.replace(/{{|}}/g, ""); diff --git a/packages/core/src/tests/context.test.ts b/packages/core/src/tests/context.test.ts new file mode 100644 index 0000000000..6bf391282b --- /dev/null +++ b/packages/core/src/tests/context.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { composeContext } from "../context"; +import handlebars from "handlebars"; +import { State } from "../types.ts"; + +describe("composeContext", () => { + const baseState: State = { + actors: "", + recentMessages: "", + recentMessagesData: [], + roomId: "-----", + bio: "", + lore: "", + messageDirections: "", + postDirections: "", + userName: "", + }; + + // Test simple string replacement + describe("simple string replacement (default)", () => { + it("should replace placeholders with corresponding state values", () => { + const state: State = { + ...baseState, + userName: "Alice", + userAge: 30, + }; + const template = + "Hello, {{userName}}! You are {{userAge}} years old."; + + const result = composeContext({ state, template }); + + expect(result).toBe("Hello, Alice! You are 30 years old."); + }); + + it("should replace missing state values with empty string", () => { + const state: State = { + ...baseState, + userName: "Alice", + }; + const template = + "Hello, {{userName}}! You are {{userAge}} years old."; + + const result = composeContext({ state, template }); + + expect(result).toBe("Hello, Alice! You are years old."); + }); + + it("should handle templates with no placeholders", () => { + const state: State = { + ...baseState, + userName: "Alice", + }; + const template = "Hello, world!"; + + const result = composeContext({ state, template }); + + expect(result).toBe("Hello, world!"); + }); + + it("should handle empty template", () => { + const state: State = { + ...baseState, + userName: "Alice", + }; + const template = ""; + + const result = composeContext({ state, template }); + + expect(result).toBe(""); + }); + }); + + // Test Handlebars templating + describe("handlebars templating", () => { + it("should process basic handlebars template", () => { + const state: State = { + ...baseState, + userName: "Alice", + userAge: 30, + }; + const template = + "Hello, {{userName}}! You are {{userAge}} years old."; + + const result = composeContext({ + state, + template, + templatingEngine: "handlebars", + }); + + expect(result).toBe("Hello, Alice! You are 30 years old."); + }); + + it("should handle handlebars conditionals", () => { + const state: State = { + ...baseState, + userName: "Alice", + userAge: 30, + }; + const template = + "{{#if userAge}}Age: {{userAge}}{{else}}Age unknown{{/if}}"; + + const result = composeContext({ + state, + template, + templatingEngine: "handlebars", + }); + + expect(result).toBe("Age: 30"); + }); + + it("should handle handlebars loops", () => { + const state: State = { + ...baseState, + colors: ["red", "blue", "green"], + }; + const template = + "{{#each colors}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}"; + + const result = composeContext({ + state, + template, + templatingEngine: "handlebars", + }); + + expect(result).toBe("red, blue, green"); + }); + + it("should handle complex handlebars template", () => { + // Register the 'gt' helper before running tests + handlebars.registerHelper("gt", function (a, b) { + return a > b; + }); + + const state = { + ...baseState, + userName: "Alice", + userAge: 30, + favoriteColors: ["blue", "green", "red"], + }; + const template = ` + {{#if userAge}} + Hello, {{userName}}! {{#if (gt userAge 18)}}You are an adult.{{else}}You are a minor.{{/if}} + {{else}} + Hello! We don't know your age. + {{/if}} + {{#each favoriteColors}} + - {{this}} + {{/each}}`; + + const result = composeContext({ + state, + template, + templatingEngine: "handlebars", + }); + + expect(result.trim()).toMatch(/Hello, Alice! You are an adult./); + expect(result).toContain("- blue"); + expect(result).toContain("- green"); + expect(result).toContain("- red"); + }); + + it("should handle missing values in handlebars template", () => { + const state = {...baseState} + const template = "Hello, {{userName}}!"; + + const result = composeContext({ + state, + template, + templatingEngine: "handlebars", + }); + + expect(result).toBe("Hello, !"); + }); + }); + + describe("error handling", () => { + it("should handle undefined state", () => { + const template = "Hello, {{userName}}!"; + + expect(() => { + // @ts-expect-error testing undefined state + composeContext({ template }); + }).toThrow(); + }); + + it("should handle undefined template", () => { + const state = { + ...baseState, + userName: "Alice", + }; + + expect(() => { + // @ts-expect-error testing undefined template + composeContext({ state }); + }).toThrow(); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1aeac6c64..06fcbc3f3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -650,6 +650,9 @@ importers: glob: specifier: 11.0.0 version: 11.0.0 + handlebars: + specifier: ^4.7.8 + version: 4.7.8 js-sha1: specifier: 0.7.0 version: 0.7.0