Files
superwork/vscode/src/settings/promptMigration.ts
T

240 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 存量自定义提示词的一次性迁移:单花括号 → 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: '审查提示词末尾已追加「审查结论」,其余段落未改' }],
}
}