import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { getGlobalExtensionDir } from "../config/paths.ts"; /** 包内内置模板目录,随 npm 包只读发布。 */ const BUILTIN_TEMPLATES_DIR = fileURLToPath(new URL("../../templates", import.meta.url)); const TEMPLATE_FILE_EXTENSION = ".md"; const TEMPLATE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const WINDOWS_RESERVED_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; const MAX_TEMPLATE_NAME_CHARS = 64; /** 内置兜底模板名;自定义模板读取失败时回退到它。 */ export const DEFAULT_TEMPLATE_NAME = "default"; export interface LoadedTemplate { /** 最终发送给模型的系统提示词。 */ content: string; /** 配置请求的模板名。 */ requestedName: string; /** 实际加载的模板名。 */ resolvedName: string; /** 自定义模板回退时的可展示原因。 */ fallbackReason?: string; } /** 判断模板名是否为跨平台安全且不含路径分隔符的文件名。 */ export function isValidTemplateName(name: string): boolean { return ( name.length <= MAX_TEMPLATE_NAME_CHARS && TEMPLATE_NAME_PATTERN.test(name) && !WINDOWS_RESERVED_NAME_PATTERN.test(name) ); } /** 返回用户模板目录 ~/.pi/agent/ai-git-commit/templates/。 */ export function getUserTemplatesDir(): string { return join(getGlobalExtensionDir(), "templates"); } /** 返回包内内置模板目录。 */ export function getBuiltinTemplatesDir(): string { return BUILTIN_TEMPLATES_DIR; } /** 判断错误是否为文件不存在。 */ function isFileNotFound(error: unknown): boolean { return error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT"; } /** 把未知错误转换为可展示原因。 */ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } /** 直接读取包内 default,不经过用户同名覆盖。 */ async function readBuiltinDefault(): Promise { const defaultPath = join( BUILTIN_TEMPLATES_DIR, `${DEFAULT_TEMPLATE_NAME}${TEMPLATE_FILE_EXTENSION}`, ); const content = await readFile(defaultPath, "utf8"); if (!content.trim()) { throw new Error(`Built-in default template ${defaultPath} is empty.`); } return content; } /** * 按名字解析模板内容。 * 解析顺序:用户模板目录优先,包内内置模板兜底;两处都不存在时报错。 */ async function readTemplateByName(name: string): Promise { if (!isValidTemplateName(name)) { throw new Error( `Template name "${name}" is invalid. Use up to ${MAX_TEMPLATE_NAME_CHARS} letters, digits, dots, hyphens, or underscores, starting with a letter or digit.`, ); } const fileName = `${name}${TEMPLATE_FILE_EXTENSION}`; const candidates = [join(getUserTemplatesDir(), fileName), join(BUILTIN_TEMPLATES_DIR, fileName)]; for (const path of candidates) { let content: string; try { content = await readFile(path, "utf8"); } catch (error) { if (isFileNotFound(error)) { continue; } throw new Error(`Failed to read template file ${path}: ${getErrorMessage(error)}`); } if (!content.trim()) { throw new Error(`Template file ${path} is empty.`); } return content; } throw new Error(`Template "${name}" was not found. Searched:\n${candidates.join("\n")}`); } /** * 加载模板及解析元数据。自定义模板失败时回退到包内 default; * 用户主动覆盖的 default 失败时保持失败,不隐藏用户文件问题。 */ export async function loadTemplate(name: string): Promise { try { return { content: await readTemplateByName(name), requestedName: name, resolvedName: name, }; } catch (error) { if (name === DEFAULT_TEMPLATE_NAME) { throw error; } return { content: await readBuiltinDefault(), requestedName: name, resolvedName: DEFAULT_TEMPLATE_NAME, fallbackReason: getErrorMessage(error), }; } } /** 加载模板内容;保留给无需回退元数据的调用方。 */ export async function loadTemplateContent(name: string): Promise { return (await loadTemplate(name)).content; } /** 按 Unicode 字符校验模板内容不超过配置的 maxDiffChars。 */ export function assertTemplateWithinBudget(template: LoadedTemplate, maxDiffChars: number): void { const characterCount = Array.from(template.content).length; if (characterCount > maxDiffChars) { throw new Error( `Template "${template.resolvedName}" contains ${characterCount} Unicode characters, exceeding maxDiffChars (${maxDiffChars}). Shorten the template or increase maxDiffChars in ai-git-commit.json.`, ); } }