diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 694dbb9..945bf0d 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -6,6 +6,10 @@ on:
pull_request:
branches: ['main']
+permissions:
+ contents: read
+ pull-requests: read
+
jobs:
build:
name: Build and Test
diff --git a/.github/workflows/issue-sync.yaml b/.github/workflows/issue-sync.yaml
index e7161cf..2d0e80b 100644
--- a/.github/workflows/issue-sync.yaml
+++ b/.github/workflows/issue-sync.yaml
@@ -4,6 +4,10 @@ on:
issues:
types: ['opened', 'reopened']
+permissions:
+ contents: read
+ pull-requests: read
+
jobs:
sync:
name: Sync Issues
diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml
index 78cd8d0..9a8c902 100644
--- a/.github/workflows/license-check.yaml
+++ b/.github/workflows/license-check.yaml
@@ -5,7 +5,10 @@ on:
branches: ['ci/**', 'main']
pull_request:
branches: ['main']
- workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: read
jobs:
license-check:
diff --git a/.github/workflows/pr-sync.yaml b/.github/workflows/pr-sync.yaml
index 1fabede..447db6e 100644
--- a/.github/workflows/pr-sync.yaml
+++ b/.github/workflows/pr-sync.yaml
@@ -4,6 +4,10 @@ on:
pull_request_target:
types: ['opened', 'reopened', 'closed']
+permissions:
+ contents: read
+ pull-requests: read
+
jobs:
sync:
name: Send Lark Message
diff --git a/.github/workflows/semantic-pull-request.yaml b/.github/workflows/semantic-pull-request.yaml
index 5e32b8f..5cf6353 100644
--- a/.github/workflows/semantic-pull-request.yaml
+++ b/.github/workflows/semantic-pull-request.yaml
@@ -2,10 +2,11 @@ name: Semantic Pull Request
on:
pull_request:
- types:
- - opened
- - reopened
- - edited
+ types: ['opened', 'reopened', 'edited']
+
+permissions:
+ contents: read
+ pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.number }}
diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml
index 3b7a6f0..ddd4393 100644
--- a/common/config/rush/pnpm-lock.yaml
+++ b/common/config/rush/pnpm-lock.yaml
@@ -325,6 +325,9 @@ importers:
node-fetch:
specifier: ^2.x
version: 2.7.0
+ nunjucks:
+ specifier: ^3.2.4
+ version: 3.2.4
promise-retry:
specifier: ~2.0.1
version: 2.0.1
@@ -356,6 +359,9 @@ importers:
'@types/node-fetch':
specifier: ^2.x
version: 2.6.12
+ '@types/nunjucks':
+ specifier: ^3.2.6
+ version: 3.2.6
'@types/promise-retry':
specifier: ~1.1.6
version: 1.1.6
@@ -2062,6 +2068,9 @@ packages:
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
+ '@types/nunjucks@3.2.6':
+ resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==}
+
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@@ -2305,6 +2314,9 @@ packages:
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
+ a-sync-waterfall@1.0.1:
+ resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==}
+
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -2401,6 +2413,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
+ asap@2.0.6:
+ resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2588,6 +2603,10 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
+ commander@5.1.0:
+ resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
+ engines: {node: '>= 6'}
+
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -3786,6 +3805,16 @@ packages:
normalize-package-data@2.5.0:
resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
+ nunjucks@3.2.4:
+ resolution: {integrity: sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==}
+ engines: {node: '>= 6.9.0'}
+ hasBin: true
+ peerDependencies:
+ chokidar: ^3.3.0
+ peerDependenciesMeta:
+ chokidar:
+ optional: true
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -6518,6 +6547,8 @@ snapshots:
'@types/normalize-package-data@2.4.4': {}
+ '@types/nunjucks@3.2.6': {}
+
'@types/parse-json@4.0.2': {}
'@types/promise-retry@1.1.6':
@@ -6863,6 +6894,8 @@ snapshots:
loupe: 3.1.3
tinyrainbow: 1.2.0
+ a-sync-waterfall@1.0.1: {}
+
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@@ -6976,6 +7009,8 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
+ asap@2.0.6: {}
+
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
@@ -7192,6 +7227,8 @@ snapshots:
commander@4.1.1: {}
+ commander@5.1.0: {}
+
concat-map@0.0.1: {}
confusing-browser-globals@1.0.11: {}
@@ -8531,6 +8568,12 @@ snapshots:
semver: 5.7.2
validate-npm-package-license: 3.0.4
+ nunjucks@3.2.4:
+ dependencies:
+ a-sync-waterfall: 1.0.1
+ asap: 2.0.6
+ commander: 5.1.0
+
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
diff --git a/examples/cozeloop-ai-node/src/index.ts b/examples/cozeloop-ai-node/src/index.ts
index f1f37ad..08fe489 100644
--- a/examples/cozeloop-ai-node/src/index.ts
+++ b/examples/cozeloop-ai-node/src/index.ts
@@ -1,7 +1,7 @@
import { config } from 'dotenv';
import { run as runTrace } from './tracer';
-import { run as runPromptHub } from './prompt/hub';
+import { run as runPromptHub } from './prompt';
import { run as runOAuthJwt } from './auth/oauth-jwt';
import { run as runApiClient } from './api/api-client';
@@ -25,6 +25,8 @@ async function run() {
.catch(e => console.error(`❌ ${task.name} error=${e}`));
await Promise.all(tasks.map(it => runTask(it)));
+
+ process.exit(0);
}
config();
diff --git a/examples/cozeloop-ai-node/src/prompt/hub.ts b/examples/cozeloop-ai-node/src/prompt/hub.ts
index 08550c8..8da0584 100644
--- a/examples/cozeloop-ai-node/src/prompt/hub.ts
+++ b/examples/cozeloop-ai-node/src/prompt/hub.ts
@@ -14,12 +14,12 @@ export async function run() {
// 1. getPrompt
const key = 'loop';
- const version = '0.0.3';
+ const version = '0.0.1';
const prompt = await hub.getPrompt(key, version);
// {
- // workspace_id: '7306823955623854124',
+ // workspace_id: '7308703665823416358',
// prompt_key: 'loop',
- // version: '0.0.3',
+ // version: '0.0.1',
// prompt_template: {
// template_type: 'normal',
// messages: [
diff --git a/examples/cozeloop-ai-node/src/prompt/index.ts b/examples/cozeloop-ai-node/src/prompt/index.ts
new file mode 100644
index 0000000..7b3ea49
--- /dev/null
+++ b/examples/cozeloop-ai-node/src/prompt/index.ts
@@ -0,0 +1,8 @@
+import { run as runWithJinja } from './with-jinja';
+import { run as runBasic } from './hub';
+
+export async function run() {
+ await Promise.all([runBasic(), runWithJinja()]);
+
+ process.exit(0);
+}
diff --git a/examples/cozeloop-ai-node/src/prompt/with-jinja.ts b/examples/cozeloop-ai-node/src/prompt/with-jinja.ts
new file mode 100644
index 0000000..1010719
--- /dev/null
+++ b/examples/cozeloop-ai-node/src/prompt/with-jinja.ts
@@ -0,0 +1,68 @@
+/* eslint-disable max-len -- skip */
+import assert from 'node:assert';
+
+import { type Message, type PromptVariables, PromptHub } from '@cozeloop/ai';
+
+export async function run() {
+ const hub = new PromptHub({
+ /** workspace id, use process.env.COZELOOP_WORKSPACE_ID when unprovided */
+ // workspaceId: 'your_workspace_id',
+ apiClient: {
+ // baseURL: 'api_base_url',
+ // token: 'your_api_token',
+ },
+ });
+
+ // 1. getPrompt
+ const key = 'loop12';
+ const version = '0.0.3';
+ const prompt = await hub.getPrompt(key, version);
+ // {
+ // workspace_id: '7308703665823416358',
+ // prompt_key: 'loop12',
+ // version: '0.0.3',
+ // prompt_template: {
+ // template_type: 'normal',
+ // messages: [
+ // {
+ // role: 'system',
+ // content:
+ // '{# 注释:这是一个 Jinja2 模板示例 #}\n标题: {{ title | default("默认标题") }}\n\n{%- if user.is_authenticated %}\n你好, {{ user.name }}!\n{%- else %}\n请登录。\n{%- endif %}\n\n项目列表:\n{%- for item in items %}\n - {{ loop.index }}: {{ item.name | upper }}\n{%- else %}\n - 暂无项目。\n{%- endfor %}\n\n原始HTML: {{ "不转义" | safe }}\n\n{# 定义一个宏 #}\n{% macro greet(person) %}\nHello, {{ person }}!\n{% endmacro %}\n{{ greet(user.name) }}\n\n项目总数: {{ items | length }}',
+ // },
+ // { role: 'placeholder', content: 'pl' },
+ // ],
+ // },
+ // llm_config: { max_tokens: 1000, top_p: 1, temperature: 0.7 },
+ // tools: [],
+ // }
+
+ assert.strictEqual(prompt?.prompt_key, key);
+ assert.strictEqual(prompt.version, version);
+
+ // 2. formatPrompt with variables
+ const placeholderMessages: Message[] = [
+ { role: 'assistant', content: 'Hello!' },
+ { role: 'user', content: 'Hello!' },
+ ];
+ const variables: PromptVariables = {
+ title: '示例标题',
+ user: {
+ is_authenticated: true,
+ name: '张三',
+ },
+ items: [{ name: '项目一' }, { name: '项目二' }, { name: '项目三' }],
+ pl: placeholderMessages,
+ };
+ const messages = hub.formatPrompt(prompt, variables);
+ // [
+ // {
+ // role: 'system',
+ // content:
+ // '\n标题: 示例标题\n你好, 张三!\n\n项目列表:\n - 1: 项目一\n - 2: 项目二\n - 3: 项目三\n\n原始HTML: 不转义\n\n\n\n\nHello, 张三!\n\n\n项目总数: 3',
+ // },
+ // { role: 'assistant', content: 'Hello!' },
+ // { role: 'user', content: 'Hello!' },
+ // ];
+
+ assert.ok(messages.length);
+}
diff --git a/packages/cozeloop-ai/CHANGELOG.md b/packages/cozeloop-ai/CHANGELOG.md
index fa97f8a..4dcbc0f 100644
--- a/packages/cozeloop-ai/CHANGELOG.md
+++ b/packages/cozeloop-ai/CHANGELOG.md
@@ -1,4 +1,7 @@
-# 🕗 Change Log - @cozeloop/ai
+# 🕗 ChangeLog - @cozeloop/ai
+
+## 0.0.6
+* PromptHub: support prompt with Jinja2 template type
## 0.0.5
* fix: variable_defs may be undefined (by @othorizon)
diff --git a/packages/cozeloop-ai/__tests__/__mock__/prompt-hub.ts b/packages/cozeloop-ai/__tests__/__mock__/prompt-hub.ts
index afc4a05..71a3908 100644
--- a/packages/cozeloop-ai/__tests__/__mock__/prompt-hub.ts
+++ b/packages/cozeloop-ai/__tests__/__mock__/prompt-hub.ts
@@ -5,45 +5,70 @@ import { http } from 'msw';
import { setupMockServer, successResp } from './utils';
+const normalPrompt = {
+ query: { prompt_key: 'loop1', version: '0.0.2' },
+ prompt: {
+ llm_config: {
+ temperature: 0.7,
+ max_tokens: 1000,
+ top_p: 1,
+ },
+ workspace_id: '7306823955623854124',
+ prompt_key: 'loop1',
+ version: '0.0.2',
+ prompt_template: {
+ template_type: 'normal',
+ messages: [
+ {
+ role: 'system',
+ content: '你是一个无所不知的机器人,请认真回答用户问题{{var1}}',
+ },
+ { role: 'user', content: '用户问题是{{var2}}' },
+ { role: 'placeholder', content: 'placeholder' },
+ ],
+ variable_defs: [
+ { key: 'var1', desc: '', type: 'string' },
+ { key: 'var2', desc: '', type: 'string' },
+ { desc: '', type: 'placeholder', key: 'placeholder' },
+ ],
+ },
+ tools: [],
+ },
+};
+
+const jinja2Prompt = {
+ query: { prompt_key: 'loop12', version: '0.0.3' },
+ prompt: {
+ workspace_id: '7308703665823416358',
+ prompt_key: 'loop12',
+ version: '0.0.3',
+ prompt_template: {
+ variable_defs: [
+ { key: 'p', desc: '', type: 'placeholder' },
+ { key: 'pl', desc: '', type: 'placeholder' },
+ ],
+ template_type: 'jinja2',
+ messages: [
+ {
+ role: 'system',
+ content:
+ '{# 注释:这是一个 Jinja2 模板示例 #}\n标题: {{ title | default("默认标题") }}\n\n{%- if user.is_authenticated %}\n你好, {{ user.name }}!\n{%- else %}\n请登录。\n{%- endif %}\n\n项目列表:\n{%- for item in items %}\n - {{ loop.index }}: {{ item.name | upper }}\n{%- else %}\n - 暂无项目。\n{%- endfor %}\n\n原始HTML: {{ "不转义" | safe }}\n\n{# 定义一个宏 #}\n{% macro greet(person) %}\nHello, {{ person }}!\n{% endmacro %}\n{{ greet(user.name) }}\n\n项目总数: {{ items | length }}',
+ },
+ { role: 'placeholder', content: 'pl' },
+ ],
+ },
+ llm_config: { temperature: 1, max_tokens: 4096, top_p: 0.7 },
+ },
+};
+
export function setupPromptHubMock() {
const mockServer = setupServer(
- http.post(/\/v1\/loop\/prompts\/mget/, () =>
- successResp({
- items: [
- {
- query: { prompt_key: 'loop1', version: '0.0.2' },
- prompt: {
- llm_config: {
- temperature: 0.7,
- max_tokens: 1000,
- top_p: 1,
- },
- workspace_id: '7306823955623854124',
- prompt_key: 'loop1',
- version: '0.0.2',
- prompt_template: {
- template_type: 'normal',
- messages: [
- {
- role: 'system',
- content:
- '你是一个无所不知的机器人,请认真回答用户问题{{var1}}',
- },
- { role: 'user', content: '用户问题是{{var2}}' },
- { role: 'placeholder', content: 'placeholder' },
- ],
- variable_defs: [
- { key: 'var1', desc: '', type: 'string' },
- { key: 'var2', desc: '', type: 'string' },
- { desc: '', type: 'placeholder', key: 'placeholder' },
- ],
- },
- tools: [],
- },
- },
- ],
- }),
- ),
+ http.post(/\/v1\/loop\/prompts\/mget/, req => {
+ const templateType = req.request.headers.get('x-template-type');
+ return successResp({
+ items: [templateType === 'jinja2' ? jinja2Prompt : normalPrompt],
+ });
+ }),
);
return setupMockServer(mockServer);
diff --git a/packages/cozeloop-ai/__tests__/prompt/hub.test.ts b/packages/cozeloop-ai/__tests__/prompt/hub.test.ts
index 3d4cfe6..8c6f194 100644
--- a/packages/cozeloop-ai/__tests__/prompt/hub.test.ts
+++ b/packages/cozeloop-ai/__tests__/prompt/hub.test.ts
@@ -35,7 +35,10 @@ describe('Test Prompt Hub', () => {
it('#1 getPrompt and formatPrompt', async () => {
const hub = new PromptHub({
- traceable: true,
+ apiClient: {
+ headers: { 'x-template-type': 'normal' }, // for mock
+ },
+ traceable: false,
});
const key = 'loop1';
@@ -116,4 +119,32 @@ describe('Test Prompt Hub', () => {
// @ts-expect-error skip
expect(cache._timer).toBeUndefined();
});
+
+ it('#3 jinja2 prompt', async () => {
+ const hub = new PromptHub({
+ apiClient: {
+ headers: { 'x-template-type': 'jinja2' }, // for mock
+ },
+ traceable: false,
+ });
+
+ const key = 'loop12';
+ const version = '0.0.3';
+ const prompt = await hub.getPrompt(key, version);
+
+ expect(prompt?.prompt_key).toBe(key);
+ expect(prompt?.version).toBe(version);
+
+ const messageList = hub.formatPrompt(prompt, {
+ title: '示例标题',
+ user: {
+ is_authenticated: true,
+ name: '张三',
+ },
+ items: [{ name: '项目一' }, { name: '项目二' }, { name: '项目三' }],
+ pl: [{ role: 'user', content: 'hi hi hi' }],
+ });
+
+ expect(messageList.length).toBeGreaterThan(0);
+ });
});
diff --git a/packages/cozeloop-ai/__tests__/prompt/utils.test.ts b/packages/cozeloop-ai/__tests__/prompt/utils.test.ts
index 719a795..acd2bfc 100644
--- a/packages/cozeloop-ai/__tests__/prompt/utils.test.ts
+++ b/packages/cozeloop-ai/__tests__/prompt/utils.test.ts
@@ -120,6 +120,23 @@ describe('Test prompt/utils', () => {
]);
});
+ it('should handle jinja2 template', () => {
+ const promptTemplate: PromptTemplate = {
+ messages: [
+ { role: 'system', content: 'You are {{ user.name }}.' },
+ { role: 'placeholder', content: 'conversation' },
+ ],
+ template_type: 'jinja2',
+ variable_defs: [{ key: 'conversation', type: 'placeholder' }],
+ };
+
+ const variables: PromptVariables = {
+ user: { name: 'Bot' },
+ };
+ const result = formatPromptTemplate(promptTemplate, variables);
+ expect(result).toEqual([{ role: 'system', content: 'You are Bot.' }]);
+ });
+
it('should throw error for unsupported message role', () => {
const promptTemplate: PromptTemplate = {
messages: [
diff --git a/packages/cozeloop-ai/__tests__/tracer/adapt.test.ts b/packages/cozeloop-ai/__tests__/tracer/adapt.test.ts
new file mode 100644
index 0000000..7c9da58
--- /dev/null
+++ b/packages/cozeloop-ai/__tests__/tracer/adapt.test.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+import {
+ toLoopTraceSpanPromptTemplateInput,
+ toLoopTraceSpanPromptTemplateMessages,
+} from '../../src/tracer/utils/adapt';
+import { type PromptVariables } from '../../src/prompt';
+import { type Message } from '../../src/api/prompt';
+
+describe('loop', () => {
+ describe('toLoopTraceSpanPromptTemplateInput', () => {
+ it('should return correct LoopTracePromptTemplateInput with messages and variables', () => {
+ const messages: Message[] = [
+ { role: 'system', content: 'You are a helpful assistant' },
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there!' },
+ ];
+ const variables: PromptVariables = { name: 'John', age: 30 };
+
+ const result = toLoopTraceSpanPromptTemplateInput(messages, variables);
+
+ expect(result).toEqual({
+ templates: [
+ { role: 'system', content: 'You are a helpful assistant' },
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there!' },
+ ],
+ arguments: [
+ { key: 'name', value: 'John' },
+ { key: 'age', value: 30 },
+ ],
+ });
+ });
+
+ it('should return correct LoopTracePromptTemplateInput with no messages and no variables', () => {
+ const result = toLoopTraceSpanPromptTemplateInput();
+
+ expect(result).toEqual({
+ templates: undefined,
+ arguments: [],
+ });
+ });
+ });
+
+ describe('toLoopTraceSpanPromptTemplateMessages', () => {
+ it('should return correct LoopTraceLLMCallMessage[] with messages', () => {
+ const messages: Message[] = [
+ { role: 'system', content: 'You are a helpful assistant' },
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there!' },
+ ];
+
+ const result = toLoopTraceSpanPromptTemplateMessages(messages);
+
+ expect(result).toEqual([
+ { role: 'system', content: 'You are a helpful assistant' },
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there!' },
+ ]);
+ });
+
+ it('should return undefined with no messages', () => {
+ const result = toLoopTraceSpanPromptTemplateMessages();
+
+ expect(result).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/cozeloop-ai/package.json b/packages/cozeloop-ai/package.json
index 0774197..6a1cac0 100644
--- a/packages/cozeloop-ai/package.json
+++ b/packages/cozeloop-ai/package.json
@@ -1,6 +1,6 @@
{
"name": "@cozeloop/ai",
- "version": "0.0.5",
+ "version": "0.0.6",
"description": "Official Node.js SDK of CozeLoop | 扣子罗盘官方 Node.js SDK",
"keywords": [
"cozeloop",
@@ -58,6 +58,7 @@
"jsonwebtoken": "^9.0.2",
"nanoid": "^3.x",
"node-fetch": "^2.x",
+ "nunjucks": "^3.2.4",
"promise-retry": "~2.0.1",
"quick-lru": "^5.x",
"remeda": "^2.21.2",
@@ -70,6 +71,7 @@
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20",
"@types/node-fetch": "^2.x",
+ "@types/nunjucks": "^3.2.6",
"@types/promise-retry": "~1.1.6",
"@vitest/coverage-v8": "~2.1.4",
"axios": "^1.8.2",
diff --git a/packages/cozeloop-ai/src/api/prompt/types.ts b/packages/cozeloop-ai/src/api/prompt/types.ts
index ddb4bc1..2f4194a 100644
--- a/packages/cozeloop-ai/src/api/prompt/types.ts
+++ b/packages/cozeloop-ai/src/api/prompt/types.ts
@@ -19,7 +19,7 @@ export interface VariableDef {
}
export interface PromptTemplate {
- template_type: 'normal';
+ template_type: 'normal' | 'jinja2';
messages: Message[];
variable_defs: VariableDef[];
}
diff --git a/packages/cozeloop-ai/src/prompt/types.ts b/packages/cozeloop-ai/src/prompt/types.ts
index ebd1580..ea78c19 100644
--- a/packages/cozeloop-ai/src/prompt/types.ts
+++ b/packages/cozeloop-ai/src/prompt/types.ts
@@ -28,10 +28,12 @@ export interface PromptHubOptions {
logger?: SimpleLogger;
}
-export type PromptVariables = Record<
- string,
- string | number | bigint | boolean | symbol | Message | Message[]
->;
+export type PromptVariables =
+ | Record<
+ string,
+ string | number | bigint | boolean | symbol | Message | Message[]
+ >
+ | Record;
export type PromptVariableMap = Record<
string,
diff --git a/packages/cozeloop-ai/src/prompt/utils.ts b/packages/cozeloop-ai/src/prompt/utils.ts
index 08d2edd..b19eb3c 100644
--- a/packages/cozeloop-ai/src/prompt/utils.ts
+++ b/packages/cozeloop-ai/src/prompt/utils.ts
@@ -1,5 +1,7 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
+import nj from 'nunjucks';
+
import { stringifyVal } from '../utils/common';
import type {
FormattedMessage,
@@ -31,6 +33,27 @@ function buildVariableMap(
return variableMap;
}
+function interpolateJinja(content: string, variables?: PromptVariables) {
+ if (!variables || !Object.keys(variables).length) {
+ return content;
+ }
+
+ return nj.renderString(content, variables);
+}
+
+function interpolateNormal(content: string, variableMap?: PromptVariableMap) {
+ if (!variableMap || !Object.keys(variableMap).length) {
+ return content;
+ }
+
+ return content.replace(/\{\{([a-zA-Z]\w{0,49})\}\}/gm, (_, key) => {
+ const val = variableMap[key];
+
+ // only replace variable with string type
+ return val?.def.type === 'string' ? stringifyVal(val.value) : `{{${key}}}`;
+ });
+}
+
export function formatPromptTemplate(
promptTemplate?: PromptTemplate,
variables?: PromptVariables,
@@ -42,9 +65,19 @@ export function formatPromptTemplate(
const { messages, template_type, variable_defs = [] } = promptTemplate;
const variableMap = buildVariableMap(variable_defs, variables);
const formattedMessages: Message[] = [];
+ const interpolator = (content: string) => {
+ switch (template_type) {
+ case 'normal':
+ return interpolateNormal(content, variableMap);
+ case 'jinja2':
+ return interpolateJinja(content, variables);
+ default:
+ return content;
+ }
+ };
messages.forEach(it => {
- formattedMessages.push(...formatMessage(it, template_type, variableMap));
+ formattedMessages.push(...formatMessage(it, variableMap, interpolator));
});
return formattedMessages as FormattedMessage[];
@@ -52,8 +85,8 @@ export function formatPromptTemplate(
function formatMessage(
message: Message,
- templateType: PromptTemplate['template_type'],
variableMap: PromptVariableMap,
+ interpolator: (content: string) => string,
): Message[] {
const { role, content = '' } = message;
@@ -62,50 +95,19 @@ function formatMessage(
case 'user':
case 'assistant':
case 'tool':
- return [
- { role, content: interpolateText(content, templateType, variableMap) },
- ];
+ return [{ role, content: interpolator(content) }];
case 'placeholder':
- return interpolatePlaceholder(content, templateType, variableMap);
+ return interpolatePlaceholder(content, variableMap);
default:
throw new Error(`[formatMessage] unsupported message role ${role}`);
}
}
-function interpolateText(
- content: string,
- templateType: PromptTemplate['template_type'],
- variableMap: PromptVariableMap,
-) {
- // content is empty
- // only support normal template now
- // no variables
- if (
- templateType !== 'normal' ||
- !content ||
- !Object.keys(variableMap).length
- ) {
- return content;
- }
-
- return content.replace(/\{\{([a-zA-Z]\w{0,49})\}\}/gm, (_, key) => {
- const val = variableMap[key];
-
- // only replace variable with string type
- return val?.def.type === 'string' ? stringifyVal(val.value) : `{{${key}}}`;
- });
-}
-
function interpolatePlaceholder(
placeholderName: string,
- templateType: PromptTemplate['template_type'],
variableMap: PromptVariableMap,
) {
- if (
- !placeholderName ||
- templateType !== 'normal' ||
- !Object.keys(variableMap).length
- ) {
+ if (!placeholderName || !Object.keys(variableMap).length) {
return [];
}