240 lines
8.5 KiB
TypeScript
240 lines
8.5 KiB
TypeScript
/**
|
||
* 存量自定义提示词的一次性迁移:单花括号 → 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<string, unknown> | null
|
||
notes: MigrationNote[]
|
||
}
|
||
|
||
/** `{foo}` → `{{foo}}`,只认已知占位符;已经是 `{{foo}}` 的不动。 */
|
||
export function upgradePlaceholders(text: string): string {
|
||
const names = KNOWN_PLACEHOLDERS.join('|')
|
||
return text.replace(new RegExp(`(?<!\\{)\\{(${names})\\}(?!\\})`, 'g'), '{{$1}}')
|
||
}
|
||
|
||
/**
|
||
* 把含 `{{name}}` 的整行包进 `{{#if name}}`。
|
||
*
|
||
* 旧引擎对原型段落的做法是「占位符取值为空就删掉整行」,只有原型会话 id
|
||
* 会真的为空;迁移后用条件块把这条语义原样搬过来。
|
||
*/
|
||
export function guardLinesWith(text: string, name: string): string {
|
||
const token = `{{${name}}}`
|
||
return text
|
||
.split('\n')
|
||
.map(line => 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<string, unknown>, key: string): string {
|
||
const v = stored[key]
|
||
return typeof v === 'string' ? v : ''
|
||
}
|
||
|
||
/**
|
||
* 算出迁移后的 settings blob。`defaultBrainstorm` 是内置的合并版模板,用来补
|
||
* 用户没存过的那一半分支——比如只改过「新建工单」提示词的人,续聊分支直接用
|
||
* 内置默认,不然合并出来的模板会有一路是空的。
|
||
*/
|
||
export function planPromptMigration(
|
||
stored: Record<string, unknown>,
|
||
defaultBrainstorm: string,
|
||
): PromptMigrationPlan {
|
||
const version = stored.promptTemplateVersion
|
||
if (typeof version === 'number' && version >= PROMPT_TEMPLATE_VERSION)
|
||
return { blob: null, notes: [] }
|
||
|
||
const next: Record<string, unknown> = { ...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: '审查提示词末尾已追加「审查结论」,其余段落未改' }],
|
||
}
|
||
}
|