@kognitivedev/vercel-ai-provider 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -8
- package/dist/__tests__/template.test.d.ts +1 -0
- package/dist/__tests__/template.test.js +112 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +6 -9
- package/dist/template.d.ts +2 -0
- package/dist/template.js +13 -0
- package/package.json +4 -1
- package/src/__tests__/template.test.ts +161 -0
- package/src/handlebars.d.ts +4 -0
- package/src/index.ts +5 -11
- package/src/template.ts +10 -0
package/README.md
CHANGED
|
@@ -151,7 +151,7 @@ Used with `cl.streamText()` and `cl.generateText()` to reference a managed promp
|
|
|
151
151
|
```typescript
|
|
152
152
|
interface PromptConfig {
|
|
153
153
|
slug: string;
|
|
154
|
-
variables?: Record<string, string>;
|
|
154
|
+
variables?: Record<string, string | boolean>;
|
|
155
155
|
}
|
|
156
156
|
```
|
|
157
157
|
|
|
@@ -265,7 +265,69 @@ const { text } = await generateText({
|
|
|
265
265
|
|
|
266
266
|
Use `cl.streamText()` and `cl.generateText()` to resolve prompts by slug from the backend. Prompts are fetched from the `/api/cognitive/prompt` endpoint and cached for 60 seconds.
|
|
267
267
|
|
|
268
|
-
|
|
268
|
+
Prompt templates use [Handlebars](https://handlebarsjs.com/) syntax, giving you variable interpolation and conditional blocks. This means prompts can be fully managed from the dashboard without any string concatenation in application code.
|
|
269
|
+
|
|
270
|
+
#### Template Syntax
|
|
271
|
+
|
|
272
|
+
**Variable interpolation** — insert a value by name:
|
|
273
|
+
|
|
274
|
+
```handlebars
|
|
275
|
+
Hello {{userName}}, welcome to {{companyName}}.
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Conditional blocks** — include content only when a variable is truthy:
|
|
279
|
+
|
|
280
|
+
```handlebars
|
|
281
|
+
{{#if hasImages}}
|
|
282
|
+
The user has attached images. Please analyze them.
|
|
283
|
+
{{/if}}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**If / else** — provide a fallback when the condition is falsy:
|
|
287
|
+
|
|
288
|
+
```handlebars
|
|
289
|
+
{{#if isPremium}}
|
|
290
|
+
You have access to all features.
|
|
291
|
+
{{else}}
|
|
292
|
+
Upgrade to premium for full access.
|
|
293
|
+
{{/if}}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**Unless** — inverse of `if`, includes the block when the variable is falsy:
|
|
297
|
+
|
|
298
|
+
```handlebars
|
|
299
|
+
{{#unless hasHistory}}
|
|
300
|
+
This is a new user. Introduce yourself.
|
|
301
|
+
{{/unless}}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Nesting** — conditionals can be nested arbitrarily:
|
|
305
|
+
|
|
306
|
+
```handlebars
|
|
307
|
+
{{#if hasAttachments}}
|
|
308
|
+
Attachments provided.
|
|
309
|
+
{{#if hasImages}}
|
|
310
|
+
Includes images for visual analysis.
|
|
311
|
+
{{/if}}
|
|
312
|
+
{{/if}}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
#### Variable Types
|
|
316
|
+
|
|
317
|
+
Variables can be `string` or `boolean`:
|
|
318
|
+
|
|
319
|
+
| Type | Truthy | Falsy |
|
|
320
|
+
|------|--------|-------|
|
|
321
|
+
| `string` | Any non-empty string | `""` (empty string) |
|
|
322
|
+
| `boolean` | `true` | `false` |
|
|
323
|
+
|
|
324
|
+
Undefined variables render as empty string in interpolation and are treated as falsy in conditionals.
|
|
325
|
+
|
|
326
|
+
> **Note:** Content is not HTML-escaped — prompt text passes through as-is, which is the correct behavior for LLM prompts.
|
|
327
|
+
|
|
328
|
+
#### Usage with `cl.streamText()` / `cl.generateText()`
|
|
329
|
+
|
|
330
|
+
Pass variables in the `prompt.variables` field:
|
|
269
331
|
|
|
270
332
|
```typescript
|
|
271
333
|
// Stream with a managed prompt
|
|
@@ -275,13 +337,15 @@ const result = await cl.streamText({
|
|
|
275
337
|
sessionId: "session-abc"
|
|
276
338
|
}),
|
|
277
339
|
prompt: {
|
|
278
|
-
slug: "
|
|
340
|
+
slug: "fitness-coach",
|
|
279
341
|
variables: {
|
|
280
|
-
|
|
281
|
-
|
|
342
|
+
userName: "Sarah",
|
|
343
|
+
fitnessGoal: "Build muscle",
|
|
344
|
+
hasImages: true,
|
|
345
|
+
hasAttachments: false
|
|
282
346
|
}
|
|
283
347
|
},
|
|
284
|
-
messages: [{ role: "user", content: "
|
|
348
|
+
messages: [{ role: "user", content: "Check my squat form" }]
|
|
285
349
|
});
|
|
286
350
|
|
|
287
351
|
for await (const chunk of result.textStream) {
|
|
@@ -290,19 +354,59 @@ for await (const chunk of result.textStream) {
|
|
|
290
354
|
```
|
|
291
355
|
|
|
292
356
|
```typescript
|
|
293
|
-
// Generate with a managed prompt
|
|
357
|
+
// Generate with a managed prompt (string-only variables work too)
|
|
294
358
|
const { text } = await cl.generateText({
|
|
295
359
|
model: cl("gpt-4o", {
|
|
296
360
|
userId: "user-123",
|
|
297
361
|
sessionId: "session-abc"
|
|
298
362
|
}),
|
|
299
363
|
prompt: {
|
|
300
|
-
slug: "summarizer"
|
|
364
|
+
slug: "summarizer",
|
|
365
|
+
variables: {
|
|
366
|
+
language: "English"
|
|
367
|
+
}
|
|
301
368
|
},
|
|
302
369
|
messages: [{ role: "user", content: "Summarize this document..." }]
|
|
303
370
|
});
|
|
304
371
|
```
|
|
305
372
|
|
|
373
|
+
#### Standalone `renderTemplate()`
|
|
374
|
+
|
|
375
|
+
The template engine is also exported directly for use outside of prompt management:
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
import { renderTemplate } from "@kognitivedev/vercel-ai-provider";
|
|
379
|
+
|
|
380
|
+
const result = renderTemplate(
|
|
381
|
+
"Hello {{name}}! {{#if isVip}}Welcome back, VIP.{{/if}}",
|
|
382
|
+
{ name: "Alice", isVip: true }
|
|
383
|
+
);
|
|
384
|
+
// → "Hello Alice! Welcome back, VIP."
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
#### Full Example Template
|
|
388
|
+
|
|
389
|
+
A prompt template stored in the dashboard might look like:
|
|
390
|
+
|
|
391
|
+
```handlebars
|
|
392
|
+
You are a fitness coach AI assistant.
|
|
393
|
+
|
|
394
|
+
User: {{userName}}
|
|
395
|
+
Goal: {{fitnessGoal}}
|
|
396
|
+
|
|
397
|
+
{{#if hasImages}}
|
|
398
|
+
The user has attached images for form analysis. Please review them carefully.
|
|
399
|
+
{{/if}}
|
|
400
|
+
{{#if hasAttachments}}
|
|
401
|
+
Additional documents have been provided for context.
|
|
402
|
+
{{/if}}
|
|
403
|
+
{{#unless hasHistory}}
|
|
404
|
+
This is a new user with no prior conversation history. Introduce yourself.
|
|
405
|
+
{{/unless}}
|
|
406
|
+
|
|
407
|
+
Please provide personalized advice.
|
|
408
|
+
```
|
|
409
|
+
|
|
306
410
|
### Gateway Routing
|
|
307
411
|
|
|
308
412
|
When a prompt has a `gatewaySlug` configured on the backend, requests are automatically routed through the gateway endpoint at `{baseUrl}/api/cognitive/gateway/{gatewaySlug}`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const template_1 = require("../template");
|
|
5
|
+
(0, vitest_1.describe)("renderTemplate", () => {
|
|
6
|
+
(0, vitest_1.describe)("variable interpolation", () => {
|
|
7
|
+
(0, vitest_1.it)("replaces a single variable", () => {
|
|
8
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("Hello {{name}}", { name: "Alice" })).toBe("Hello Alice");
|
|
9
|
+
});
|
|
10
|
+
(0, vitest_1.it)("replaces multiple variables", () => {
|
|
11
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("{{greeting}}, {{name}}!", { greeting: "Hi", name: "Bob" })).toBe("Hi, Bob!");
|
|
12
|
+
});
|
|
13
|
+
(0, vitest_1.it)("renders undefined variables as empty string", () => {
|
|
14
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("Hello {{name}}", {})).toBe("Hello ");
|
|
15
|
+
});
|
|
16
|
+
(0, vitest_1.it)("does not HTML-escape content", () => {
|
|
17
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("{{content}}", { content: "<b>bold</b> & \"quoted\"" })).toBe("<b>bold</b> & \"quoted\"");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
(0, vitest_1.describe)("{{#if}} / {{/if}}", () => {
|
|
21
|
+
(0, vitest_1.it)("includes block when variable is truthy string", () => {
|
|
22
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("start{{#if show}} visible{{/if}} end", { show: "yes" })).toBe("start visible end");
|
|
23
|
+
});
|
|
24
|
+
(0, vitest_1.it)("includes block when variable is true", () => {
|
|
25
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("start{{#if show}} visible{{/if}} end", { show: true })).toBe("start visible end");
|
|
26
|
+
});
|
|
27
|
+
(0, vitest_1.it)("excludes block when variable is false", () => {
|
|
28
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("start{{#if show}} visible{{/if}} end", { show: false })).toBe("start end");
|
|
29
|
+
});
|
|
30
|
+
(0, vitest_1.it)("excludes block when variable is undefined", () => {
|
|
31
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("start{{#if show}} visible{{/if}} end", {})).toBe("start end");
|
|
32
|
+
});
|
|
33
|
+
(0, vitest_1.it)("excludes block when variable is empty string", () => {
|
|
34
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("start{{#if show}} visible{{/if}} end", { show: "" })).toBe("start end");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
(0, vitest_1.describe)("{{#unless}} / {{/unless}}", () => {
|
|
38
|
+
(0, vitest_1.it)("includes block when variable is falsy", () => {
|
|
39
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("{{#unless premium}}Free tier{{/unless}}", { premium: false })).toBe("Free tier");
|
|
40
|
+
});
|
|
41
|
+
(0, vitest_1.it)("excludes block when variable is truthy", () => {
|
|
42
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("{{#unless premium}}Free tier{{/unless}}", { premium: true })).toBe("");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
(0, vitest_1.describe)("{{else}} branches", () => {
|
|
46
|
+
(0, vitest_1.it)("renders else branch when if condition is false", () => {
|
|
47
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("{{#if vip}}VIP access{{else}}Standard access{{/if}}", { vip: false })).toBe("Standard access");
|
|
48
|
+
});
|
|
49
|
+
(0, vitest_1.it)("renders if branch when condition is true", () => {
|
|
50
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("{{#if vip}}VIP access{{else}}Standard access{{/if}}", { vip: true })).toBe("VIP access");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
(0, vitest_1.describe)("nested conditionals", () => {
|
|
54
|
+
(0, vitest_1.it)("handles nested if blocks", () => {
|
|
55
|
+
const template = "{{#if a}}A{{#if b}}-B{{/if}}{{/if}}";
|
|
56
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)(template, { a: true, b: true })).toBe("A-B");
|
|
57
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)(template, { a: true, b: false })).toBe("A");
|
|
58
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)(template, { a: false, b: true })).toBe("");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
(0, vitest_1.describe)("variables inside conditionals", () => {
|
|
62
|
+
(0, vitest_1.it)("interpolates variables within conditional blocks", () => {
|
|
63
|
+
(0, vitest_1.expect)((0, template_1.renderTemplate)("{{#if hasName}}Name: {{name}}{{/if}}", { hasName: true, name: "Alice" })).toBe("Name: Alice");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
(0, vitest_1.describe)("real-world fitness app template", () => {
|
|
67
|
+
const template = `You are a fitness coach AI assistant.
|
|
68
|
+
|
|
69
|
+
User: {{userName}}
|
|
70
|
+
Goal: {{fitnessGoal}}
|
|
71
|
+
|
|
72
|
+
{{#if hasImages}}
|
|
73
|
+
The user has attached images for form analysis. Please review them carefully.
|
|
74
|
+
{{/if}}
|
|
75
|
+
{{#if hasAttachments}}
|
|
76
|
+
Additional documents have been provided for context.
|
|
77
|
+
{{/if}}
|
|
78
|
+
{{#unless hasHistory}}
|
|
79
|
+
This is a new user with no prior conversation history. Introduce yourself.
|
|
80
|
+
{{/unless}}
|
|
81
|
+
|
|
82
|
+
Please provide personalized advice.`;
|
|
83
|
+
(0, vitest_1.it)("renders with all flags true", () => {
|
|
84
|
+
const result = (0, template_1.renderTemplate)(template, {
|
|
85
|
+
userName: "Sarah",
|
|
86
|
+
fitnessGoal: "Build muscle",
|
|
87
|
+
hasImages: true,
|
|
88
|
+
hasAttachments: true,
|
|
89
|
+
hasHistory: true,
|
|
90
|
+
});
|
|
91
|
+
(0, vitest_1.expect)(result).toContain("User: Sarah");
|
|
92
|
+
(0, vitest_1.expect)(result).toContain("Goal: Build muscle");
|
|
93
|
+
(0, vitest_1.expect)(result).toContain("attached images for form analysis");
|
|
94
|
+
(0, vitest_1.expect)(result).toContain("Additional documents");
|
|
95
|
+
(0, vitest_1.expect)(result).not.toContain("new user with no prior");
|
|
96
|
+
});
|
|
97
|
+
(0, vitest_1.it)("renders with all flags false", () => {
|
|
98
|
+
const result = (0, template_1.renderTemplate)(template, {
|
|
99
|
+
userName: "Tom",
|
|
100
|
+
fitnessGoal: "Lose weight",
|
|
101
|
+
hasImages: false,
|
|
102
|
+
hasAttachments: false,
|
|
103
|
+
hasHistory: false,
|
|
104
|
+
});
|
|
105
|
+
(0, vitest_1.expect)(result).toContain("User: Tom");
|
|
106
|
+
(0, vitest_1.expect)(result).toContain("Goal: Lose weight");
|
|
107
|
+
(0, vitest_1.expect)(result).not.toContain("attached images");
|
|
108
|
+
(0, vitest_1.expect)(result).not.toContain("Additional documents");
|
|
109
|
+
(0, vitest_1.expect)(result).toContain("new user with no prior");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { streamText as aiStreamText, generateText as aiGenerateText, type LanguageModel } from "ai";
|
|
2
|
+
export { renderTemplate, type TemplateVariables } from "./template";
|
|
2
3
|
/**
|
|
3
4
|
* Log levels for controlling verbosity of CognitiveLayer logging.
|
|
4
5
|
* - 'none': No logging
|
|
@@ -36,7 +37,7 @@ export type CLModelWrapper = (modelId: string, settings?: {
|
|
|
36
37
|
}, providerOptions?: Record<string, unknown>) => LanguageModel;
|
|
37
38
|
export interface PromptConfig {
|
|
38
39
|
slug: string;
|
|
39
|
-
variables?: Record<string, string>;
|
|
40
|
+
variables?: Record<string, string | boolean>;
|
|
40
41
|
}
|
|
41
42
|
export type CLStreamTextOptions = Omit<Parameters<typeof aiStreamText>[0], 'system' | 'prompt'> & {
|
|
42
43
|
prompt: PromptConfig;
|
package/dist/index.js
CHANGED
|
@@ -11,9 +11,13 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
11
11
|
return t;
|
|
12
12
|
};
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.renderTemplate = void 0;
|
|
14
15
|
exports.createCognitiveLayer = createCognitiveLayer;
|
|
15
16
|
const ai_1 = require("ai");
|
|
16
17
|
const crypto_1 = require("crypto");
|
|
18
|
+
var template_1 = require("./template");
|
|
19
|
+
Object.defineProperty(exports, "renderTemplate", { enumerable: true, get: function () { return template_1.renderTemplate; } });
|
|
20
|
+
const template_2 = require("./template");
|
|
17
21
|
function isValidId(value) {
|
|
18
22
|
if (value == null || typeof value !== "string")
|
|
19
23
|
return false;
|
|
@@ -154,13 +158,6 @@ function buildTraceSpansFromMessages(messages) {
|
|
|
154
158
|
}
|
|
155
159
|
return spans;
|
|
156
160
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Interpolate {{variable}} placeholders in a template string.
|
|
159
|
-
* Unmatched variables are left as-is.
|
|
160
|
-
*/
|
|
161
|
-
function interpolateTemplate(content, variables) {
|
|
162
|
-
return content.replace(/\{\{(\w+)\}\}/g, (_, key) => { var _a; return (_a = variables[key]) !== null && _a !== void 0 ? _a : `{{${key}}}`; });
|
|
163
|
-
}
|
|
164
161
|
// Session-scoped snapshot cache: sessionKey → formatted memory block
|
|
165
162
|
const sessionSnapshots = new Map();
|
|
166
163
|
// Regex to detect if memory has already been injected
|
|
@@ -640,7 +637,7 @@ ${userContextBlock || "None"}
|
|
|
640
637
|
let system;
|
|
641
638
|
if (resolved) {
|
|
642
639
|
system = promptConfig.variables
|
|
643
|
-
?
|
|
640
|
+
? (0, template_2.renderTemplate)(resolved.content, promptConfig.variables)
|
|
644
641
|
: resolved.content;
|
|
645
642
|
// Store prompt metadata for the session (read by middleware during logging)
|
|
646
643
|
if (session === null || session === void 0 ? void 0 : session.sessionId) {
|
|
@@ -679,7 +676,7 @@ ${userContextBlock || "None"}
|
|
|
679
676
|
let system;
|
|
680
677
|
if (resolved) {
|
|
681
678
|
system = promptConfig.variables
|
|
682
|
-
?
|
|
679
|
+
? (0, template_2.renderTemplate)(resolved.content, promptConfig.variables)
|
|
683
680
|
: resolved.content;
|
|
684
681
|
// Store prompt metadata for the session (read by middleware during logging)
|
|
685
682
|
if (session === null || session === void 0 ? void 0 : session.sessionId) {
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderTemplate = renderTemplate;
|
|
7
|
+
// Use the pre-built dist to avoid `require.extensions` warning in webpack/Next.js
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
9
|
+
const handlebars_1 = __importDefault(require("handlebars/dist/cjs/handlebars"));
|
|
10
|
+
function renderTemplate(template, variables) {
|
|
11
|
+
const compiled = handlebars_1.default.compile(template, { noEscape: true });
|
|
12
|
+
return compiled(variables);
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kognitivedev/vercel-ai-provider",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"publishConfig": {
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"test": "vitest run",
|
|
13
13
|
"prepublishOnly": "npm run build"
|
|
14
14
|
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"handlebars": "^4.7.8"
|
|
17
|
+
},
|
|
15
18
|
"peerDependencies": {
|
|
16
19
|
"ai": "^5.0.0 || ^6.0.0"
|
|
17
20
|
},
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderTemplate } from "../template";
|
|
3
|
+
|
|
4
|
+
describe("renderTemplate", () => {
|
|
5
|
+
describe("variable interpolation", () => {
|
|
6
|
+
it("replaces a single variable", () => {
|
|
7
|
+
expect(renderTemplate("Hello {{name}}", { name: "Alice" })).toBe("Hello Alice");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("replaces multiple variables", () => {
|
|
11
|
+
expect(
|
|
12
|
+
renderTemplate("{{greeting}}, {{name}}!", { greeting: "Hi", name: "Bob" })
|
|
13
|
+
).toBe("Hi, Bob!");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("renders undefined variables as empty string", () => {
|
|
17
|
+
expect(renderTemplate("Hello {{name}}", {})).toBe("Hello ");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not HTML-escape content", () => {
|
|
21
|
+
expect(renderTemplate("{{content}}", { content: "<b>bold</b> & \"quoted\"" })).toBe(
|
|
22
|
+
"<b>bold</b> & \"quoted\""
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("{{#if}} / {{/if}}", () => {
|
|
28
|
+
it("includes block when variable is truthy string", () => {
|
|
29
|
+
expect(
|
|
30
|
+
renderTemplate("start{{#if show}} visible{{/if}} end", { show: "yes" })
|
|
31
|
+
).toBe("start visible end");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("includes block when variable is true", () => {
|
|
35
|
+
expect(
|
|
36
|
+
renderTemplate("start{{#if show}} visible{{/if}} end", { show: true })
|
|
37
|
+
).toBe("start visible end");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("excludes block when variable is false", () => {
|
|
41
|
+
expect(
|
|
42
|
+
renderTemplate("start{{#if show}} visible{{/if}} end", { show: false })
|
|
43
|
+
).toBe("start end");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("excludes block when variable is undefined", () => {
|
|
47
|
+
expect(
|
|
48
|
+
renderTemplate("start{{#if show}} visible{{/if}} end", {})
|
|
49
|
+
).toBe("start end");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("excludes block when variable is empty string", () => {
|
|
53
|
+
expect(
|
|
54
|
+
renderTemplate("start{{#if show}} visible{{/if}} end", { show: "" })
|
|
55
|
+
).toBe("start end");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("{{#unless}} / {{/unless}}", () => {
|
|
60
|
+
it("includes block when variable is falsy", () => {
|
|
61
|
+
expect(
|
|
62
|
+
renderTemplate("{{#unless premium}}Free tier{{/unless}}", { premium: false })
|
|
63
|
+
).toBe("Free tier");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("excludes block when variable is truthy", () => {
|
|
67
|
+
expect(
|
|
68
|
+
renderTemplate("{{#unless premium}}Free tier{{/unless}}", { premium: true })
|
|
69
|
+
).toBe("");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("{{else}} branches", () => {
|
|
74
|
+
it("renders else branch when if condition is false", () => {
|
|
75
|
+
expect(
|
|
76
|
+
renderTemplate(
|
|
77
|
+
"{{#if vip}}VIP access{{else}}Standard access{{/if}}",
|
|
78
|
+
{ vip: false }
|
|
79
|
+
)
|
|
80
|
+
).toBe("Standard access");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("renders if branch when condition is true", () => {
|
|
84
|
+
expect(
|
|
85
|
+
renderTemplate(
|
|
86
|
+
"{{#if vip}}VIP access{{else}}Standard access{{/if}}",
|
|
87
|
+
{ vip: true }
|
|
88
|
+
)
|
|
89
|
+
).toBe("VIP access");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("nested conditionals", () => {
|
|
94
|
+
it("handles nested if blocks", () => {
|
|
95
|
+
const template = "{{#if a}}A{{#if b}}-B{{/if}}{{/if}}";
|
|
96
|
+
expect(renderTemplate(template, { a: true, b: true })).toBe("A-B");
|
|
97
|
+
expect(renderTemplate(template, { a: true, b: false })).toBe("A");
|
|
98
|
+
expect(renderTemplate(template, { a: false, b: true })).toBe("");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("variables inside conditionals", () => {
|
|
103
|
+
it("interpolates variables within conditional blocks", () => {
|
|
104
|
+
expect(
|
|
105
|
+
renderTemplate(
|
|
106
|
+
"{{#if hasName}}Name: {{name}}{{/if}}",
|
|
107
|
+
{ hasName: true, name: "Alice" }
|
|
108
|
+
)
|
|
109
|
+
).toBe("Name: Alice");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("real-world fitness app template", () => {
|
|
114
|
+
const template = `You are a fitness coach AI assistant.
|
|
115
|
+
|
|
116
|
+
User: {{userName}}
|
|
117
|
+
Goal: {{fitnessGoal}}
|
|
118
|
+
|
|
119
|
+
{{#if hasImages}}
|
|
120
|
+
The user has attached images for form analysis. Please review them carefully.
|
|
121
|
+
{{/if}}
|
|
122
|
+
{{#if hasAttachments}}
|
|
123
|
+
Additional documents have been provided for context.
|
|
124
|
+
{{/if}}
|
|
125
|
+
{{#unless hasHistory}}
|
|
126
|
+
This is a new user with no prior conversation history. Introduce yourself.
|
|
127
|
+
{{/unless}}
|
|
128
|
+
|
|
129
|
+
Please provide personalized advice.`;
|
|
130
|
+
|
|
131
|
+
it("renders with all flags true", () => {
|
|
132
|
+
const result = renderTemplate(template, {
|
|
133
|
+
userName: "Sarah",
|
|
134
|
+
fitnessGoal: "Build muscle",
|
|
135
|
+
hasImages: true,
|
|
136
|
+
hasAttachments: true,
|
|
137
|
+
hasHistory: true,
|
|
138
|
+
});
|
|
139
|
+
expect(result).toContain("User: Sarah");
|
|
140
|
+
expect(result).toContain("Goal: Build muscle");
|
|
141
|
+
expect(result).toContain("attached images for form analysis");
|
|
142
|
+
expect(result).toContain("Additional documents");
|
|
143
|
+
expect(result).not.toContain("new user with no prior");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("renders with all flags false", () => {
|
|
147
|
+
const result = renderTemplate(template, {
|
|
148
|
+
userName: "Tom",
|
|
149
|
+
fitnessGoal: "Lose weight",
|
|
150
|
+
hasImages: false,
|
|
151
|
+
hasAttachments: false,
|
|
152
|
+
hasHistory: false,
|
|
153
|
+
});
|
|
154
|
+
expect(result).toContain("User: Tom");
|
|
155
|
+
expect(result).toContain("Goal: Lose weight");
|
|
156
|
+
expect(result).not.toContain("attached images");
|
|
157
|
+
expect(result).not.toContain("Additional documents");
|
|
158
|
+
expect(result).toContain("new user with no prior");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
type LanguageModel,
|
|
6
6
|
} from "ai";
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
|
+
export { renderTemplate, type TemplateVariables } from "./template";
|
|
9
|
+
import { renderTemplate } from "./template";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Log levels for controlling verbosity of CognitiveLayer logging.
|
|
@@ -101,7 +103,7 @@ export type CLModelWrapper = (
|
|
|
101
103
|
|
|
102
104
|
export interface PromptConfig {
|
|
103
105
|
slug: string;
|
|
104
|
-
variables?: Record<string, string>;
|
|
106
|
+
variables?: Record<string, string | boolean>;
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
export type CLStreamTextOptions = Omit<Parameters<typeof aiStreamText>[0], 'system' | 'prompt'> & {
|
|
@@ -274,14 +276,6 @@ function buildTraceSpansFromMessages(messages: any[]): Array<{
|
|
|
274
276
|
return spans;
|
|
275
277
|
}
|
|
276
278
|
|
|
277
|
-
/**
|
|
278
|
-
* Interpolate {{variable}} placeholders in a template string.
|
|
279
|
-
* Unmatched variables are left as-is.
|
|
280
|
-
*/
|
|
281
|
-
function interpolateTemplate(content: string, variables: Record<string, string>): string {
|
|
282
|
-
return content.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? `{{${key}}}`);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
279
|
// Session-scoped snapshot cache: sessionKey → formatted memory block
|
|
286
280
|
const sessionSnapshots = new Map<string, string>();
|
|
287
281
|
|
|
@@ -851,7 +845,7 @@ ${userContextBlock || "None"}
|
|
|
851
845
|
let system: string | undefined;
|
|
852
846
|
if (resolved) {
|
|
853
847
|
system = promptConfig.variables
|
|
854
|
-
?
|
|
848
|
+
? renderTemplate(resolved.content, promptConfig.variables)
|
|
855
849
|
: resolved.content;
|
|
856
850
|
|
|
857
851
|
// Store prompt metadata for the session (read by middleware during logging)
|
|
@@ -895,7 +889,7 @@ ${userContextBlock || "None"}
|
|
|
895
889
|
let system: string | undefined;
|
|
896
890
|
if (resolved) {
|
|
897
891
|
system = promptConfig.variables
|
|
898
|
-
?
|
|
892
|
+
? renderTemplate(resolved.content, promptConfig.variables)
|
|
899
893
|
: resolved.content;
|
|
900
894
|
|
|
901
895
|
// Store prompt metadata for the session (read by middleware during logging)
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Use the pre-built dist to avoid `require.extensions` warning in webpack/Next.js
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
3
|
+
import Handlebars from "handlebars/dist/cjs/handlebars";
|
|
4
|
+
|
|
5
|
+
export type TemplateVariables = Record<string, string | boolean>;
|
|
6
|
+
|
|
7
|
+
export function renderTemplate(template: string, variables: TemplateVariables): string {
|
|
8
|
+
const compiled = Handlebars.compile(template, { noEscape: true });
|
|
9
|
+
return compiled(variables);
|
|
10
|
+
}
|