58f1bc8718
- 新增 prompt-template 模块,支持运行时变量替换
- 支持 ${variable}、${obj.prop}、${cond ? "a" : "b"} 语法
- AgentInfo 新增 promptTemplate 字段标记动态模板
- Plan Agent 提示词改用模板语法,支持动态工具名和计划文件路径
- AgentExecutor.buildSystemPrompt 集成模板渲染
- 新增 27 个单元测试验证模板功能
348 lines
8.7 KiB
TypeScript
348 lines
8.7 KiB
TypeScript
/**
|
|
* 提示词模板渲染器
|
|
*
|
|
* 支持的语法:
|
|
* - ${variable} - 简单变量替换
|
|
* - ${obj.prop} - 嵌套属性访问
|
|
* - ${condition ? "trueValue" : "falseValue"} - 条件表达式
|
|
*/
|
|
|
|
import type { PromptContext, PromptTemplate, RenderOptions, ToolNameMapping } from './types.js';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
/**
|
|
* 获取嵌套属性值
|
|
*/
|
|
function getNestedValue(obj: Record<string, unknown>, pathStr: string): unknown {
|
|
const parts = pathStr.split('.');
|
|
let current: unknown = obj;
|
|
|
|
for (const part of parts) {
|
|
if (current === null || current === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof current === 'object') {
|
|
current = (current as Record<string, unknown>)[part];
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* 安全的表达式求值器
|
|
* 支持简单的属性访问和三元运算符
|
|
*/
|
|
function evaluateExpression(
|
|
expr: string,
|
|
context: PromptContext,
|
|
options: RenderOptions = {}
|
|
): string {
|
|
// 移除首尾空格
|
|
expr = expr.trim();
|
|
|
|
// 处理三元运算符: condition ? trueValue : falseValue
|
|
// 需要处理嵌套的情况,使用更精确的匹配
|
|
const ternaryMatch = findTernaryOperator(expr);
|
|
if (ternaryMatch) {
|
|
const { condition, trueValue, falseValue } = ternaryMatch;
|
|
const conditionResult = evaluateExpression(condition, context, options);
|
|
// 将结果转换为布尔值
|
|
const isTruthy =
|
|
conditionResult !== '' &&
|
|
conditionResult !== 'false' &&
|
|
conditionResult !== 'undefined' &&
|
|
conditionResult !== 'null' &&
|
|
conditionResult !== '0';
|
|
return isTruthy
|
|
? evaluateExpression(trueValue, context, options)
|
|
: evaluateExpression(falseValue, context, options);
|
|
}
|
|
|
|
// 处理字符串字面量: "string" 或 'string' 或 `string`
|
|
const stringMatch = expr.match(/^["'`]([\s\S]*)["'`]$/);
|
|
if (stringMatch) {
|
|
// 递归处理字符串中的变量插值
|
|
return renderTemplate(stringMatch[1], context, options);
|
|
}
|
|
|
|
// 处理布尔字面量
|
|
if (expr === 'true') return 'true';
|
|
if (expr === 'false') return '';
|
|
|
|
// 处理属性访问: obj.prop.subprop
|
|
const value = getNestedValue(context as unknown as Record<string, unknown>, expr);
|
|
|
|
if (value === undefined) {
|
|
if (options.throwOnUndefined) {
|
|
throw new Error(`Undefined variable: ${expr}`);
|
|
}
|
|
if (options.debug) {
|
|
console.warn(`[PromptTemplate] Undefined variable: ${expr}`);
|
|
}
|
|
return options.undefinedValue ?? '';
|
|
}
|
|
|
|
// 布尔值转换
|
|
if (typeof value === 'boolean') {
|
|
return value ? 'true' : '';
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
/**
|
|
* 查找三元运算符的各个部分
|
|
* 处理嵌套引号的情况
|
|
*/
|
|
function findTernaryOperator(
|
|
expr: string
|
|
): { condition: string; trueValue: string; falseValue: string } | null {
|
|
let depth = 0;
|
|
let inString: string | null = null;
|
|
let questionPos = -1;
|
|
let colonPos = -1;
|
|
|
|
for (let i = 0; i < expr.length; i++) {
|
|
const char = expr[i];
|
|
const prevChar = i > 0 ? expr[i - 1] : '';
|
|
|
|
// 处理字符串边界
|
|
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
|
|
if (inString === null) {
|
|
inString = char;
|
|
} else if (inString === char) {
|
|
inString = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 如果在字符串中,跳过
|
|
if (inString !== null) continue;
|
|
|
|
// 处理括号深度
|
|
if (char === '(' || char === '[' || char === '{') {
|
|
depth++;
|
|
} else if (char === ')' || char === ']' || char === '}') {
|
|
depth--;
|
|
}
|
|
|
|
// 只在顶层寻找 ? 和 :
|
|
if (depth === 0) {
|
|
if (char === '?' && questionPos === -1) {
|
|
questionPos = i;
|
|
} else if (char === ':' && questionPos !== -1 && colonPos === -1) {
|
|
colonPos = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (questionPos === -1 || colonPos === -1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
condition: expr.substring(0, questionPos).trim(),
|
|
trueValue: expr.substring(questionPos + 1, colonPos).trim(),
|
|
falseValue: expr.substring(colonPos + 1).trim(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 渲染提示词模板
|
|
*
|
|
* @param template 模板字符串
|
|
* @param context 上下文变量
|
|
* @param options 渲染选项
|
|
* @returns 渲染后的字符串
|
|
*/
|
|
export function renderTemplate(
|
|
template: string,
|
|
context: PromptContext,
|
|
options: RenderOptions = {}
|
|
): string {
|
|
// 匹配 ${...} 模式,支持嵌套括号和字符串
|
|
const result: string[] = [];
|
|
let i = 0;
|
|
|
|
while (i < template.length) {
|
|
// 查找 ${
|
|
const start = template.indexOf('${', i);
|
|
if (start === -1) {
|
|
result.push(template.substring(i));
|
|
break;
|
|
}
|
|
|
|
// 添加 ${ 之前的文本
|
|
result.push(template.substring(i, start));
|
|
|
|
// 查找匹配的 }
|
|
let depth = 1;
|
|
let j = start + 2;
|
|
let inString: string | null = null;
|
|
|
|
while (j < template.length && depth > 0) {
|
|
const char = template[j];
|
|
const prevChar = j > 0 ? template[j - 1] : '';
|
|
|
|
// 处理字符串边界
|
|
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
|
|
if (inString === null) {
|
|
inString = char;
|
|
} else if (inString === char) {
|
|
inString = null;
|
|
}
|
|
} else if (inString === null) {
|
|
if (char === '{') depth++;
|
|
else if (char === '}') depth--;
|
|
}
|
|
|
|
j++;
|
|
}
|
|
|
|
if (depth !== 0) {
|
|
// 未找到匹配的 },保留原文
|
|
result.push(template.substring(start, j));
|
|
} else {
|
|
// 提取表达式并求值
|
|
const expression = template.substring(start + 2, j - 1);
|
|
try {
|
|
const value = evaluateExpression(expression, context, options);
|
|
result.push(value);
|
|
} catch (error) {
|
|
if (options.debug) {
|
|
console.warn(`[PromptTemplate] Expression evaluation failed: ${expression}`, error);
|
|
}
|
|
result.push(`\${${expression}}`); // 保留原始文本
|
|
}
|
|
}
|
|
|
|
i = j;
|
|
}
|
|
|
|
return result.join('');
|
|
}
|
|
|
|
/**
|
|
* 渲染 PromptTemplate 对象
|
|
*/
|
|
export function render(
|
|
promptTemplate: PromptTemplate,
|
|
context: PromptContext,
|
|
options?: RenderOptions
|
|
): string {
|
|
return renderTemplate(promptTemplate.template, context, options);
|
|
}
|
|
|
|
/**
|
|
* 默认工具名称映射
|
|
*/
|
|
export const DEFAULT_TOOL_NAMES: ToolNameMapping = {
|
|
glob: 'glob',
|
|
grep: 'grep_content',
|
|
read: 'read_file',
|
|
write: 'write_file',
|
|
edit: 'edit_file',
|
|
bash: 'bash',
|
|
askUserQuestion: 'ask_user_question',
|
|
exitPlanMode: 'exit_plan_mode',
|
|
enterPlanMode: 'enter_plan_mode',
|
|
task: 'task',
|
|
todoRead: 'todoread',
|
|
todoWrite: 'todowrite',
|
|
webSearch: 'web_search',
|
|
webExtract: 'web_extract',
|
|
gitStatus: 'git_status',
|
|
gitDiff: 'git_diff',
|
|
gitLog: 'git_log',
|
|
gitBranch: 'git_branch',
|
|
repoMap: 'repo_map',
|
|
};
|
|
|
|
/**
|
|
* 创建默认的提示词上下文
|
|
*/
|
|
export function createDefaultContext(overrides?: Partial<PromptContext>): PromptContext {
|
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
const workdir = process.cwd();
|
|
|
|
return {
|
|
tools: {
|
|
...DEFAULT_TOOL_NAMES,
|
|
...overrides?.tools,
|
|
},
|
|
plan: {
|
|
isActive: false,
|
|
planExists: false,
|
|
planFilePath: path.join(homeDir, '.ai-terminal-assistant', 'plan', 'plan.md'),
|
|
allowedWritePaths: [path.join(homeDir, '.ai-terminal-assistant', 'plan', '*')],
|
|
...overrides?.plan,
|
|
},
|
|
env: {
|
|
workdir,
|
|
isGitRepo: fs.existsSync(path.join(workdir, '.git')),
|
|
platform: process.platform,
|
|
date: new Date().toISOString().split('T')[0],
|
|
homeDir,
|
|
...overrides?.env,
|
|
},
|
|
agent: {
|
|
name: 'build',
|
|
mode: 'primary',
|
|
isSubagent: false,
|
|
...overrides?.agent,
|
|
},
|
|
custom: overrides?.custom,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 检查计划文件是否存在
|
|
*/
|
|
export function checkPlanFileExists(planFilePath: string): boolean {
|
|
try {
|
|
fs.accessSync(planFilePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 创建 Plan 模式上下文
|
|
*/
|
|
export function createPlanContext(options: {
|
|
planFilePath?: string;
|
|
workdir?: string;
|
|
isSubagent?: boolean;
|
|
}): PromptContext {
|
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
const planPath =
|
|
options.planFilePath || path.join(homeDir, '.ai-terminal-assistant', 'plan', 'plan.md');
|
|
const workdir = options.workdir || process.cwd();
|
|
|
|
return createDefaultContext({
|
|
plan: {
|
|
isActive: true,
|
|
planExists: checkPlanFileExists(planPath),
|
|
planFilePath: planPath,
|
|
allowedWritePaths: [path.join(homeDir, '.ai-terminal-assistant', 'plan', '*')],
|
|
},
|
|
env: {
|
|
workdir,
|
|
isGitRepo: fs.existsSync(path.join(workdir, '.git')),
|
|
platform: process.platform,
|
|
date: new Date().toISOString().split('T')[0],
|
|
homeDir,
|
|
},
|
|
agent: {
|
|
name: 'plan',
|
|
mode: options.isSubagent ? 'subagent' : 'primary',
|
|
isSubagent: options.isSubagent || false,
|
|
},
|
|
});
|
|
}
|