/** * 存量自定义提示词的一次性迁移:单花括号 → Handlebars 双花括号, * 并把「续聊」「续聊·原型段落」两份模板并进头脑风暴模板的条件分支里。 * * 纯函数,不碰文件系统 / vscode——落盘与日志由 `store.ts` 的 * `migratePromptTemplates` 负责。 */ /** 设置里的模板引擎版本;低于它就跑一次迁移。 */ export const PROMPT_TEMPLATE_VERSION = 2 /** * 只重写这些名字的占位符。正文里其他花括号内容(比如 shell 的 `${x}`、 * 示例 JSON)保持原样,免得迁移把用户写的东西改坏。 */ const KNOWN_PLACEHOLDERS = [ 'userRequest', 'nonce', 'issueNumber', 'planFile', 'prNumber', 'prRef', 'prototypeFile', 'prototypeSessionId', 'prototypeRef', ] as const /** 迁移后仍然存在的模板字段。 */ const PROMPT_FIELDS = [ 'brainstormPrompt', 'implementPlanPrompt', 'reviewPrompt', 'testPrompt', 'prototypePrompt', ] as const /** 已经并进 brainstormPrompt、不再单独存在的字段。 */ const MERGED_FIELDS = ['brainstormContinuePrompt', 'prototypeRefPrompt'] as const export interface MigrationNote { level: 'info' | 'warn' message: string } export interface PromptMigrationPlan { /** 要写回的完整 blob;null = 没什么可迁的,不写盘。 */ blob: Record | null notes: MigrationNote[] } /** `{foo}` → `{{foo}}`,只认已知占位符;已经是 `{{foo}}` 的不动。 */ export function upgradePlaceholders(text: string): string { const names = KNOWN_PLACEHOLDERS.join('|') return text.replace(new RegExp(`(? line.includes(token) ? `{{#if ${name}}}\n${line}\n{{/if}}` : line) .join('\n') } /** * 从模板里切出 `{{#if cond}} … {{else}} … {{/if}}` 这一整块的两个分支。 * * 迁移时要拿内置默认模板的「续聊」「新建工单」分支去补用户没存过的那一半, * 所以得能把合并后的默认模板再拆开。按 `{{#if}}` / `{{/if}}` 记嵌套层数, * 只认第一层的 `{{else}}`,嵌在里面的原型条件块不会被误当成分界。 */ export function splitIfBlock(tpl: string, cond: string): { then: string, else: string } | null { const lines = tpl.split('\n') const start = lines.findIndex(line => line.trim() === `{{#if ${cond}}}`) if (start < 0) return null let depth = 1 let elseAt = -1 for (let i = start + 1; i < lines.length; i++) { const line = lines[i] if (depth === 1 && line.trim() === '{{else}}') elseAt = i depth += (line.match(/\{\{#(?:if|unless)\s/g) ?? []).length depth -= (line.match(/\{\{\/(?:if|unless)\}\}/g) ?? []).length if (depth === 0) { return { then: lines.slice(start + 1, elseAt < 0 ? i : elseAt).join('\n').trim(), else: elseAt < 0 ? '' : lines.slice(elseAt + 1, i).join('\n').trim(), } } } return null } /** * 三份存量模板拼成一份:有工单号走续聊分支,没有走新建工单分支; * 原型段落嵌在续聊分支里原来 `{{prototypeRef}}` 的位置(模板里没这个 * 占位符就接在末尾)。 */ export function composeBrainstormTemplate(parts: { newIssue: string, cont: string, proto: string }): string { const proto = parts.proto.trim() const block = proto ? `{{#if prototypeFile}}\n${guardLinesWith(proto, 'prototypeSessionId')}\n{{/if}}` : '' let cont = parts.cont if (cont.includes('{{prototypeRef}}')) cont = cont.split('{{prototypeRef}}').join(block) else if (block) cont = `${cont.trimEnd()}\n\n${block}` return `{{#if issueNumber}}\n${cont.trim()}\n{{else}}\n${parts.newIssue.trim()}\n{{/if}}\n` } function storedText(stored: Record, key: string): string { const v = stored[key] return typeof v === 'string' ? v : '' } /** * 算出迁移后的 settings blob。`defaultBrainstorm` 是内置的合并版模板,用来补 * 用户没存过的那一半分支——比如只改过「新建工单」提示词的人,续聊分支直接用 * 内置默认,不然合并出来的模板会有一路是空的。 */ export function planPromptMigration( stored: Record, defaultBrainstorm: string, ): PromptMigrationPlan { const version = stored.promptTemplateVersion if (typeof version === 'number' && version >= PROMPT_TEMPLATE_VERSION) return { blob: null, notes: [] } const next: Record = { ...stored } const notes: MigrationNote[] = [] let changed = false for (const field of [...PROMPT_FIELDS, ...MERGED_FIELDS]) { const before = storedText(stored, field) if (!before) continue const after = upgradePlaceholders(before) if (after !== before) { next[field] = after changed = true notes.push({ level: 'info', message: `${field}:占位符已改写成 {{双花括号}}` }) } } const storedNewIssue = storedText(next, 'brainstormPrompt') const storedCont = storedText(next, 'brainstormContinuePrompt') const storedProto = storedText(next, 'prototypeRefPrompt') if (storedNewIssue || storedCont || storedProto) { const branches = splitIfBlock(defaultBrainstorm, 'issueNumber') if (!branches) { notes.push({ level: 'warn', message: '内置头脑风暴模板拆不出条件分支,存量的续聊 / 原型段落没能并进来,原文见备份文件', }) } else { // 用户没存过续聊时,默认续聊分支里本来就带着原型段落,别再插一遍。 const proto = storedProto || (storedCont ? splitIfBlock(branches.then, 'prototypeFile')?.then ?? '' : '') next.brainstormPrompt = composeBrainstormTemplate({ newIssue: storedNewIssue || branches.else, cont: storedCont || branches.then, proto, }) changed = true notes.push({ level: 'info', message: `头脑风暴提示词已合并成一份条件模板(新建=${storedNewIssue ? '你的' : '内置'} / 续聊=${storedCont ? '你的' : '内置'} / 原型段落=${storedProto ? '你的' : '内置'})`, }) } } for (const field of MERGED_FIELDS) { if (field in next) { delete next[field] changed = true } } if (!changed) return { blob: null, notes: [] } next.promptTemplateVersion = PROMPT_TEMPLATE_VERSION return { blob: next, notes } } function normalizePrompt(text: string): string { return text.replace(/\r\n/g, '\n').trim() } /** 新版审查提示词里「审查结论」那一节,到下一个同级标题为止。 */ export function reviewConclusionSection(nextDefault: string): string | null { const normalized = nextDefault.replace(/\r\n/g, '\n') const heading = '## 审查结论' const start = normalized.indexOf(heading) if (start < 0) return null const rest = normalized.slice(start) const relative = rest.slice(heading.length).search(/\n## /) const section = relative < 0 ? rest : rest.slice(0, heading.length + relative) const trimmed = section.trim() return trimmed.length > 0 ? trimmed : null } /** * 设置里已经存了一份审查提示词时:和改之前的默认一样就整段换成新文件; * 用户改过、且还没有 reviewPassed,只把「审查结论」接到末尾。 * 返回 null 表示不用写盘。 */ export function planReviewPromptMigration( stored: unknown, previousDefault: string, nextDefault: string, ): { prompt: string, notes: MigrationNote[] } | null { if (typeof stored !== 'string' || stored.length === 0) return null if (normalizePrompt(stored) === normalizePrompt(previousDefault)) { if (normalizePrompt(nextDefault) === normalizePrompt(previousDefault)) return null return { prompt: nextDefault, notes: [{ level: 'info', message: '审查提示词与旧版默认一致,已换成带审查结论的新版' }], } } if (stored.includes('reviewPassed')) return null const section = reviewConclusionSection(nextDefault) if (!section) return null return { prompt: `${stored.replace(/\s+$/, '')}\n\n${section}\n`, notes: [{ level: 'info', message: '审查提示词末尾已追加「审查结论」,其余段落未改' }], } }