refactor(node-runtime): extract prompt-builder to @openchatlab/node-runtime

Move buildSystemPrompt from electron/main/ai/agent/ to shared package.
i18n t() is now injected via BuildSystemPromptOptions parameter.
Types OwnerInfo, MentionedMember, SkillContext are shared.
Electron prompt-builder.ts injects its own t() and delegates.
Added 8 unit tests with mock translation function.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-17 23:28:28 +08:00
1 parent d1942fb18f
commit 0ac90030cb
5 files changed
+239 -93

No files matched your search

+15 -93
View File
@@ -1,107 +1,29 @@
/**
* Agent 系统提示词构建
* Agent system prompt builder — Electron adapter.
* Injects the Electron i18n `t` function into the shared builder.
*/
import { t as i18nT } from '../../i18n'
import type { OwnerInfo } from '../tools/types'
import type { SkillContext } from './types'
import type { ToolContext } from '../tools/types'
import { buildSystemPrompt as buildSystemPromptCore } from '@openchatlab/node-runtime'
import type { OwnerInfo, SkillContext, MentionedMember } from '@openchatlab/node-runtime'
function agentT(key: string, locale: string, options?: Record<string, unknown>): string {
return i18nT(key, { lng: locale, ...options })
}
export type { OwnerInfo, SkillContext, MentionedMember }
/**
* 获取系统锁定部分的提示词(策略说明、时间处理等)
*
* 工具定义通过 Function Calling 的 tools 参数传递给 LLM,
* 无需在 System Prompt 中重复描述。
*/
function getLockedPromptSection(
chatType: 'group' | 'private',
ownerInfo?: OwnerInfo,
locale: string = 'zh-CN',
mentionedMembers?: ToolContext['mentionedMembers']
): string {
const now = new Date()
const dateLocale = locale.startsWith('zh') ? 'zh-CN' : 'en-US'
const currentDate = now.toLocaleDateString(dateLocale, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
const isPrivate = chatType === 'private'
const chatContext = agentT(`ai.agent.chatContext.${chatType}`, locale)
const ownerNote = ownerInfo
? agentT('ai.agent.ownerNote', locale, {
displayName: ownerInfo.displayName,
platformId: ownerInfo.platformId,
chatContext,
})
: ''
const memberNote = isPrivate
? agentT('ai.agent.memberNotePrivate', locale)
: agentT('ai.agent.memberNoteGroup', locale)
const mentionedMembersNote =
mentionedMembers && mentionedMembers.length > 0
? `${agentT('ai.agent.mentionedMembersNote', locale)}\n${mentionedMembers
.map((member) => {
const aliasPart = member.aliases.length > 0 ? ` | aliases=${member.aliases.join(',')}` : ''
return `- member_id=${member.memberId} | mention=${member.mentionText} | display_name=${member.displayName} | platform_id=${member.platformId}${aliasPart}`
})
.join('\n')}\n`
: ''
const year = now.getFullYear()
return `${agentT('ai.agent.currentDateIs', locale)} ${currentDate}。
${ownerNote}
${mentionedMembersNote}
${memberNote}
${agentT('ai.agent.timeParamsIntro', locale)}
${agentT('ai.agent.defaultYearNote', locale, { year })}
${agentT('ai.agent.responseInstruction', locale)}`
}
function getFallbackRoleDefinition(chatType: 'group' | 'private', locale: string = 'zh-CN'): string {
return agentT(`ai.agent.fallbackRoleDefinition.${chatType}`, locale)
}
/**
* 构建完整的系统提示词
*
* 系统提示词优先使用当前助手配置;若助手不存在,再退回内置默认角色定义。
*
* 最终格式:{助手系统提示词}\n\n{系统锁定段(日期/owner/时间参数/通用指引)}
*/
export function buildSystemPrompt(
chatType: 'group' | 'private' = 'group',
assistantSystemPrompt?: string,
ownerInfo?: OwnerInfo,
locale: string = 'zh-CN',
skillCtx?: SkillContext,
mentionedMembers?: ToolContext['mentionedMembers']
mentionedMembers?: MentionedMember[]
): string {
const systemPrompt = assistantSystemPrompt || getFallbackRoleDefinition(chatType, locale)
const lockedSection = getLockedPromptSection(chatType, ownerInfo, locale, mentionedMembers)
let skillSection = ''
if (skillCtx?.skillDef) {
skillSection =
`\n## ${agentT('ai.agent.currentTask', locale)}:${skillCtx.skillDef.name}\n` +
`${agentT('ai.agent.skillPriorityNote', locale)}\n` +
skillCtx.skillDef.prompt
} else if (skillCtx?.skillMenu) {
skillSection = `\n${skillCtx.skillMenu}`
}
return `${systemPrompt}${skillSection}
${lockedSection}`
return buildSystemPromptCore({
t: i18nT,
chatType,
assistantSystemPrompt,
ownerInfo,
locale,
skillCtx,
mentionedMembers,
})
}
@@ -0,0 +1,84 @@
/**
* Tests for shared prompt-builder.
*
* Run: npx tsx --test packages/node-runtime/src/ai/agent/__tests__/prompt-builder.test.ts
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildSystemPrompt } from '../prompt-builder'
import type { TranslateFn } from '../prompt-builder'
const mockT: TranslateFn = (key, options) => {
if (key.startsWith('ai.agent.fallbackRoleDefinition')) return `[fallback:${key}]`
if (key === 'ai.agent.currentDateIs') return 'Current date is'
if (key === 'ai.agent.ownerNote') return `Owner: ${(options as Record<string, unknown>)?.displayName}`
if (key === 'ai.agent.memberNoteGroup') return '[member-group]'
if (key === 'ai.agent.memberNotePrivate') return '[member-private]'
if (key === 'ai.agent.timeParamsIntro') return '[time-params]'
if (key === 'ai.agent.defaultYearNote') return `[year:${(options as Record<string, unknown>)?.year}]`
if (key === 'ai.agent.responseInstruction') return '[response-instruction]'
if (key === 'ai.agent.mentionedMembersNote') return 'Mentioned members:'
if (key === 'ai.agent.currentTask') return 'Current task'
if (key === 'ai.agent.skillPriorityNote') return '[skill-priority]'
return `[${key}]`
}
describe('buildSystemPrompt', () => {
it('uses fallback role when no assistant prompt', () => {
const result = buildSystemPrompt({ t: mockT, chatType: 'group' })
assert.ok(result.includes('[fallback:ai.agent.fallbackRoleDefinition.group]'))
})
it('uses assistant prompt when provided', () => {
const result = buildSystemPrompt({ t: mockT, assistantSystemPrompt: 'Custom assistant' })
assert.ok(result.includes('Custom assistant'))
assert.ok(!result.includes('[fallback'))
})
it('includes owner info when provided', () => {
const result = buildSystemPrompt({
t: mockT,
ownerInfo: { displayName: 'Alice', platformId: 'alice123' },
})
assert.ok(result.includes('Owner: Alice'))
})
it('includes member note for private chat', () => {
const result = buildSystemPrompt({ t: mockT, chatType: 'private' })
assert.ok(result.includes('[member-private]'))
})
it('includes mentioned members', () => {
const result = buildSystemPrompt({
t: mockT,
mentionedMembers: [{ memberId: 1, platformId: 'p1', displayName: 'Bob', aliases: ['B'], mentionText: '@Bob' }],
})
assert.ok(result.includes('member_id=1'))
assert.ok(result.includes('aliases=B'))
})
it('includes skill definition when active', () => {
const result = buildSystemPrompt({
t: mockT,
skillCtx: { skillDef: { name: 'Summarizer', prompt: 'Summarize the chat.' } },
})
assert.ok(result.includes('Current task'))
assert.ok(result.includes('Summarizer'))
assert.ok(result.includes('Summarize the chat.'))
})
it('includes skill menu when no active skill', () => {
const result = buildSystemPrompt({
t: mockT,
skillCtx: { skillMenu: '[SKILL MENU TEXT]' },
})
assert.ok(result.includes('[SKILL MENU TEXT]'))
})
it('includes date and response instruction', () => {
const result = buildSystemPrompt({ t: mockT })
assert.ok(result.includes('Current date is'))
assert.ok(result.includes('[response-instruction]'))
})
})
@@ -0,0 +1,126 @@
/**
* Agent system prompt builder — shared implementation.
*
* The i18n translation function is injected via `t` parameter,
* making this module platform-agnostic.
*/
export interface OwnerInfo {
platformId: string
displayName: string
}
export interface MentionedMember {
memberId: number
platformId: string
displayName: string
aliases: string[]
mentionText: string
}
export interface SkillContext {
skillDef?: { name: string; prompt: string }
skillMenu?: string
}
export type TranslateFn = (key: string, options?: Record<string, unknown>) => string
export interface BuildSystemPromptOptions {
t: TranslateFn
chatType?: 'group' | 'private'
assistantSystemPrompt?: string
ownerInfo?: OwnerInfo
locale?: string
skillCtx?: SkillContext
mentionedMembers?: MentionedMember[]
}
function agentT(t: TranslateFn, key: string, locale: string, options?: Record<string, unknown>): string {
return t(key, { lng: locale, ...options })
}
function getLockedPromptSection(
t: TranslateFn,
chatType: 'group' | 'private',
ownerInfo: OwnerInfo | undefined,
locale: string,
mentionedMembers: MentionedMember[] | undefined
): string {
const now = new Date()
const dateLocale = locale.startsWith('zh') ? 'zh-CN' : 'en-US'
const currentDate = now.toLocaleDateString(dateLocale, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
const isPrivate = chatType === 'private'
const chatContext = agentT(t, `ai.agent.chatContext.${chatType}`, locale)
const ownerNote = ownerInfo
? agentT(t, 'ai.agent.ownerNote', locale, {
displayName: ownerInfo.displayName,
platformId: ownerInfo.platformId,
chatContext,
})
: ''
const memberNote = isPrivate
? agentT(t, 'ai.agent.memberNotePrivate', locale)
: agentT(t, 'ai.agent.memberNoteGroup', locale)
const mentionedMembersNote =
mentionedMembers && mentionedMembers.length > 0
? `${agentT(t, 'ai.agent.mentionedMembersNote', locale)}\n${mentionedMembers
.map((member) => {
const aliasPart = member.aliases.length > 0 ? ` | aliases=${member.aliases.join(',')}` : ''
return `- member_id=${member.memberId} | mention=${member.mentionText} | display_name=${member.displayName} | platform_id=${member.platformId}${aliasPart}`
})
.join('\n')}\n`
: ''
const year = now.getFullYear()
return `${agentT(t, 'ai.agent.currentDateIs', locale)} ${currentDate}。
${ownerNote}
${mentionedMembersNote}
${memberNote}
${agentT(t, 'ai.agent.timeParamsIntro', locale)}
${agentT(t, 'ai.agent.defaultYearNote', locale, { year })}
${agentT(t, 'ai.agent.responseInstruction', locale)}`
}
function getFallbackRoleDefinition(t: TranslateFn, chatType: 'group' | 'private', locale: string): string {
return agentT(t, `ai.agent.fallbackRoleDefinition.${chatType}`, locale)
}
export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
const {
t,
chatType = 'group',
assistantSystemPrompt,
ownerInfo,
locale = 'zh-CN',
skillCtx,
mentionedMembers,
} = options
const systemPrompt = assistantSystemPrompt || getFallbackRoleDefinition(t, chatType, locale)
const lockedSection = getLockedPromptSection(t, chatType, ownerInfo, locale, mentionedMembers)
let skillSection = ''
if (skillCtx?.skillDef) {
skillSection =
`\n## ${agentT(t, 'ai.agent.currentTask', locale)}:${skillCtx.skillDef.name}\n` +
`${agentT(t, 'ai.agent.skillPriorityNote', locale)}\n` +
skillCtx.skillDef.prompt
} else if (skillCtx?.skillMenu) {
skillSection = `\n${skillCtx.skillMenu}`
}
return `${systemPrompt}${skillSection}
${lockedSection}`
}
+10
View File
@@ -62,6 +62,16 @@ export type { PreprocessingPipelineOptions, PreprocessingPipelineResult } from '
export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './agent'
export { runAgentCore } from './agent'
// Agent Prompt Builder
export { buildSystemPrompt } from './agent/prompt-builder'
export type {
BuildSystemPromptOptions,
OwnerInfo,
MentionedMember,
SkillContext,
TranslateFn,
} from './agent/prompt-builder'
// LLM Model Builder
export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './llm-builder'
export type { PiModelConfig, BuildPiModelOptions } from './llm-builder'
+4
View File
@@ -88,6 +88,10 @@ export type {
export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './ai'
export { runAgentCore } from './ai'
// Agent Prompt Builder
export { buildSystemPrompt } from './ai'
export type { BuildSystemPromptOptions, OwnerInfo, MentionedMember, SkillContext, TranslateFn } from './ai'
// LLM Model Builder
export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './ai'
export type { PiModelConfig, BuildPiModelOptions } from './ai'