diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 74fdc28..3e5aad9 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -11,6 +11,7 @@ import { setProfilesDirOverride } from './cc/profiles' import { registerCodeReader } from './codeReader' import { logger } from './logging/logger' import { KanbanWebviewPanel } from './panel/KanbanPanel' +import { offerPromptUpdates } from './settings/promptUpdate' import { getSettings, migratePromptTemplates, persistSeededProfiles } from './settings/store' import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler' import { webhookCoordinator } from './webhook/coordinator' @@ -43,6 +44,7 @@ export function activate(context: ExtensionContext): SuperpowersApi { // 存量提示词先迁到 Handlebars,再让 persistSeededProfiles 在迁移结果上补 seed。 for (const note of migratePromptTemplates(context)) logger.add({ level: note.level, source: 'settings', message: note.message }) + void offerPromptUpdates(context) void persistSeededProfiles(context) // Start the webhook server immediately so PR callbacks are received even // when the Kanban panel is closed. Disposed automatically via subscriptions. diff --git a/vscode/src/settings/promptMigration.test.ts b/vscode/src/settings/promptMigration.test.ts index 691d1cb..4962a41 100644 --- a/vscode/src/settings/promptMigration.test.ts +++ b/vscode/src/settings/promptMigration.test.ts @@ -4,10 +4,8 @@ import { describe, expect, it } from 'vitest' import { PREVIOUS_DEFAULT_REVIEW_PROMPT } from './previousReviewPrompt' import { composeBrainstormTemplate, - planDefaultPromptReplacement, planPromptMigration, planReviewPromptMigration, - PREVIOUS_PROTOTYPE_PROMPTS, PROMPT_TEMPLATE_VERSION, splitIfBlock, upgradePlaceholders, @@ -191,15 +189,3 @@ describe('planReviewPromptMigration', () => { expect(planReviewPromptMigration(undefined, PREVIOUS_DEFAULT_REVIEW_PROMPT, nextDefault)).toBeNull() }) }) - -describe('planDefaultPromptReplacement', () => { - it('旧的 Figma 插件提示词换成通用技能', () => { - const next = '/ecc:ui-prototyping 给 {{issueNumber}} 号工单画原型。\n' - const out = planDefaultPromptReplacement(PREVIOUS_PROTOTYPE_PROMPTS[0], PREVIOUS_PROTOTYPE_PROMPTS, next, '已换成通用技能') - expect(out?.prompt).toBe(next) - }) - - it('改过的提示词不动', () => { - expect(planDefaultPromptReplacement('我自己写的原型提示词', PREVIOUS_PROTOTYPE_PROMPTS, '新的', '已换')).toBeNull() - }) -}) diff --git a/vscode/src/settings/promptMigration.ts b/vscode/src/settings/promptMigration.ts index 0b7edcb..8f33d2f 100644 --- a/vscode/src/settings/promptMigration.ts +++ b/vscode/src/settings/promptMigration.ts @@ -237,31 +237,3 @@ export function planReviewPromptMigration( notes: [{ level: 'info', message: '审查提示词末尾已追加「审查结论」,其余段落未改' }], } } - -/** 存的提示词和旧默认一样时,整段换成新默认。改过的不动。 */ -export function planDefaultPromptReplacement( - stored: unknown, - previousDefaults: string[], - nextDefault: string, - note: string, -): { prompt: string, notes: MigrationNote[] } | null { - if (typeof stored !== 'string' || stored.length === 0) - return null - const now = normalizePrompt(stored) - const next = normalizePrompt(nextDefault) - if (!next || now === next) - return null - if (!previousDefaults.some(prev => normalizePrompt(prev) === now)) - return null - return { - prompt: nextDefault.endsWith('\n') ? nextDefault : `${nextDefault}\n`, - notes: [{ level: 'info', message: note }], - } -} - -export const PREVIOUS_PROTOTYPE_PROMPTS = [ - '/ecc:figma-plugin-prototyping 给 {{issueNumber}} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。', - `/ecc:figma-plugin-prototyping 给 {{issueNumber}} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。 - -画完用 \`opencli spx issue marker --issue {{issueNumber}} --type prototype --value \` 把原型插件挂到工单上,再汇报。`, -] diff --git a/vscode/src/settings/promptUpdate.test.ts b/vscode/src/settings/promptUpdate.test.ts new file mode 100644 index 0000000..4f77d44 --- /dev/null +++ b/vscode/src/settings/promptUpdate.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { promptDigest, promptUpdatesToAsk } from './promptUpdatePlan' + +describe('promptUpdatesToAsk', () => { + it('和自带的不一样就问', () => { + const next = '新版原型提示词' + const out = promptUpdatesToAsk({ + stored: { prototypePrompt: '旧的 Figma 插件提示词' }, + defaults: { prototypePrompt: next }, + decided: {}, + }) + expect(out).toEqual([{ + key: 'prototypePrompt', + label: '原型提示词', + next, + hash: promptDigest(next), + }]) + }) + + it('空的、一样的、这一版已经选过的不问', () => { + const next = '新版' + expect(promptUpdatesToAsk({ + stored: { testPrompt: '' }, + defaults: { testPrompt: next }, + decided: {}, + })).toEqual([]) + expect(promptUpdatesToAsk({ + stored: { testPrompt: next }, + defaults: { testPrompt: `${next}\n` }, + decided: {}, + })).toEqual([]) + expect(promptUpdatesToAsk({ + stored: { testPrompt: '我改过' }, + defaults: { testPrompt: next }, + decided: { testPrompt: promptDigest(next) }, + })).toEqual([]) + }) +}) diff --git a/vscode/src/settings/promptUpdate.ts b/vscode/src/settings/promptUpdate.ts new file mode 100644 index 0000000..bad5e0e --- /dev/null +++ b/vscode/src/settings/promptUpdate.ts @@ -0,0 +1,45 @@ +import type { ExtensionContext } from 'vscode' +import type { PromptUpdateKey } from './promptUpdatePlan' +import { window } from 'vscode' +import { PROMPT_UPDATE_FIELDS, promptUpdatesToAsk } from './promptUpdatePlan' +import { readDefaultPrompt, readStoredSettings, saveSettings } from './store' + +function decisionsOf(stored: Record): Record { + const raw = stored.promptDefaultChoices + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) + return {} + const out: Record = {} + for (const [key, value] of Object.entries(raw)) { + if (typeof value === 'string') + out[key] = value + } + return out +} + +/** 自带提示词和设置里的不一样时问一句。选过的同一版不再问。 */ +export async function offerPromptUpdates(ctx: ExtensionContext): Promise { + const stored = readStoredSettings(ctx) as Record + const defaults = Object.fromEntries( + PROMPT_UPDATE_FIELDS.map(field => [field.key, readDefaultPrompt(ctx.extensionPath, field.file)]), + ) as Partial> + const pending = promptUpdatesToAsk({ stored, defaults, decided: decisionsOf(stored) }) + if (pending.length === 0) + return + const choices = { ...decisionsOf(stored) } + const patch: Record = {} + for (const item of pending) { + const pick = await window.showInformationMessage( + `${item.label}和这一版自带的不一样。要换成新版吗?`, + '使用新版', + '保留现有', + ) + if (pick !== '使用新版' && pick !== '保留现有') + continue + choices[item.key] = item.hash + if (pick === '使用新版') + patch[item.key] = item.next + } + if (Object.keys(patch).length === 0 && Object.keys(choices).length === Object.keys(decisionsOf(stored)).length) + return + await saveSettings(ctx, { ...patch, promptDefaultChoices: choices }) +} diff --git a/vscode/src/settings/promptUpdatePlan.ts b/vscode/src/settings/promptUpdatePlan.ts new file mode 100644 index 0000000..299005a --- /dev/null +++ b/vscode/src/settings/promptUpdatePlan.ts @@ -0,0 +1,46 @@ +import { createHash } from 'node:crypto' + +export const PROMPT_UPDATE_FIELDS = [ + { key: 'brainstormPrompt', file: 'brainstorm', label: '头脑风暴提示词' }, + { key: 'implementPlanPrompt', file: 'implement-plan', label: '实施提示词' }, + { key: 'reviewPrompt', file: 'review', label: '审查提示词' }, + { key: 'testPrompt', file: 'test', label: '测试提示词' }, + { key: 'prototypePrompt', file: 'prototype', label: '原型提示词' }, +] as const + +export type PromptUpdateKey = typeof PROMPT_UPDATE_FIELDS[number]['key'] + +export interface PromptUpdateCandidate { + key: PromptUpdateKey + label: string + next: string + hash: string +} + +export function promptDigest(text: string): string { + return createHash('sha256').update(text.replace(/\r\n/g, '\n').trim()).digest('hex') +} + +/** 设置里有内容,且和这一版自带的不一样,并且这一版还没问过。 */ +export function promptUpdatesToAsk(opts: { + stored: Record + defaults: Partial> + decided: Record +}): PromptUpdateCandidate[] { + const out: PromptUpdateCandidate[] = [] + for (const field of PROMPT_UPDATE_FIELDS) { + const saved = opts.stored[field.key] + const next = opts.defaults[field.key] + if (typeof saved !== 'string' || saved.trim() === '') + continue + if (typeof next !== 'string' || next.trim() === '') + continue + if (promptDigest(saved) === promptDigest(next)) + continue + const hash = promptDigest(next) + if (opts.decided[field.key] === hash) + continue + out.push({ key: field.key, label: field.label, next, hash }) + } + return out +} diff --git a/vscode/src/settings/store.ts b/vscode/src/settings/store.ts index f436b38..5f9edfe 100644 --- a/vscode/src/settings/store.ts +++ b/vscode/src/settings/store.ts @@ -19,7 +19,7 @@ import { seedCommitProfilePath } from '../cc/commitProfile' import { listClaudeProfiles } from '../cc/profiles' import { normalizeReleasePrefix } from '../git/release' import { PREVIOUS_DEFAULT_REVIEW_PROMPT } from './previousReviewPrompt' -import { planDefaultPromptReplacement, planPromptMigration, planReviewPromptMigration, PREVIOUS_PROTOTYPE_PROMPTS } from './promptMigration' +import { planPromptMigration, planReviewPromptMigration } from './promptMigration' /** * Fallback prompt strings used when the on-disk markdown can't be read. @@ -101,6 +101,11 @@ export interface Settings { testPrompt: string /** 原型会话首个提示词模板;常用 `{{issueNumber}}`。 */ prototypePrompt: string + /** + * 某一版自带提示词用户已经选过「使用新版」或「保留现有」。 + * 键是提示词字段,值是那一版自带正文的摘要。正文再变就再问。 + */ + promptDefaultChoices?: Record /** Day-to-day development branch (e.g. `main`). Jenkins doesn't watch it. */ devBranch: string /** @@ -524,17 +529,9 @@ export function migratePromptTemplates(ctx: ExtensionContext): MigrationNote[] { PREVIOUS_DEFAULT_REVIEW_PROMPT, readDefaultPrompt(ctx.extensionPath, 'review'), ) - let blob = review + const blob = review ? { ...(planned.blob ?? stored), reviewPrompt: review.prompt } : planned.blob - const prototype = planDefaultPromptReplacement( - (blob ?? stored).prototypePrompt, - PREVIOUS_PROTOTYPE_PROMPTS, - readDefaultPrompt(ctx.extensionPath, 'prototype'), - '原型提示词还是旧的 Figma 插件技能,已换成通用的 ui-prototyping', - ) - if (prototype) - blob = { ...(blob ?? stored), prototypePrompt: prototype.prompt } if (!blob) return [] const out: MigrationNote[] = [] @@ -556,8 +553,6 @@ export function migratePromptTemplates(ctx: ExtensionContext): MigrationNote[] { out.push(...planned.notes) if (review) out.push(...review.notes) - if (prototype) - out.push(...prototype.notes) if (planned.blob) out.push({ level: 'info', message: '提示词模板已迁到 Handlebars' }) return out