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 []; }