✨ feat(vscode): 问 AI 和重写注释各用各的 profile,设置里加重写注释默认模型
This commit is contained in:
1 parent
925f1ed39a
commit
b83c445dd0
25 files changed
+363
-111
No files matched your search
@@ -107,6 +107,9 @@ export interface QaMessage {
|
||||
rewriteCurrent: AskRound[]
|
||||
}
|
||||
|
||||
/** 预览里的两种对话:问 AI(ask,块下面和页面顶部)、重写注释(rewrite),各有各的记录和 profile 下拉 */
|
||||
export type ChatKind = 'ask' | 'rewrite'
|
||||
|
||||
/**
|
||||
* VS Code 的 Sticky Scroll 设置(editor.stickyScroll.enabled / editor.stickyScroll.maxLineCount):卡片的开头滚到预览顶上后面、卡片还有一部分看得见时把它钉在顶上,
|
||||
* 外层的在上、里层的在下,最多钉 maxLineCount 行。
|
||||
@@ -126,7 +129,7 @@ export interface StickyScrollSettings {
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选和「只看改动」开关;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
|
||||
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
|
||||
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
|
||||
* profiles:kind 那种对话输入框旁边的 profile 下拉:选项和选中的那项;ready 后两种各发一次,任何一个预览改了哪种就把哪种发给所有预览
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
|
||||
* rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
|
||||
@@ -157,7 +160,7 @@ export type ExtensionToWebview
|
||||
| { type: 'displayNames', version: number, names: ShownName[] }
|
||||
| ({ type: 'filter' } & FilterState)
|
||||
| QaMessage
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
| ({ type: 'profiles', kind: ChatKind } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true }
|
||||
@@ -184,7 +187,7 @@ export type ExtensionToWebview
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
|
||||
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
|
||||
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
|
||||
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用问 AI 眼下选的 profile;
|
||||
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
|
||||
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段);askId 由 webview 起,过程用 rewriteProgress / rewriteEnd 带回去。
|
||||
* 没有 request 是点了按钮:发技能命令,开新会话;有 request 是在输入框里说的修改意见,接着这块最近一次重写的会话说,images 是贴的图片
|
||||
@@ -193,7 +196,7 @@ export type ExtensionToWebview
|
||||
* deleteRewrite:删掉 anchor 那块下面的一条重写记录
|
||||
* stopAsk:叫停 askId 那个问题,这次不存
|
||||
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
|
||||
* selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览
|
||||
* selectProfile:kind 那种对话换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后把这种的 profiles 发给所有预览
|
||||
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发;
|
||||
* 扩展端按它设 when 子句用的上下文键
|
||||
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
|
||||
@@ -211,7 +214,7 @@ export type WebviewToExtension
|
||||
| { type: 'rewriteComment', askId: string, anchor: string, request?: string, images: ClaudeImage[] }
|
||||
| { type: 'stopRewrite', askId: string }
|
||||
| { type: 'deleteRewrite', anchor: string, entryId: string }
|
||||
| { type: 'forgetRound', kind: 'ask' | 'rewrite', anchor: string }
|
||||
| { type: 'forgetRound', kind: ChatKind, anchor: string }
|
||||
| { type: 'selectTranslator', id: string }
|
||||
| { type: 'visibleLine', line: number }
|
||||
| { type: 'probeDefinition', id: number, version: number, offset: number }
|
||||
@@ -222,7 +225,7 @@ export type WebviewToExtension
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, images: ClaudeImage[] }
|
||||
| { type: 'stopAsk', askId: string }
|
||||
| { type: 'deleteQa', anchor: string, entryId: string }
|
||||
| { type: 'selectAskProfile', id: string }
|
||||
| { type: 'selectProfile', kind: ChatKind, id: string }
|
||||
| { type: 'occurrenceNavigation', available: boolean }
|
||||
| { type: 'openRepoLink', path?: string, symbol?: string }
|
||||
| { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DiffBase, OpenedFrom } from '../diff/diffBase'
|
||||
import type { ProfileChoice } from '../qa/askProfiles'
|
||||
import type { RewriteRequest } from '../qa/codeQa'
|
||||
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { workspace } from 'vscode'
|
||||
import { window, workspace } from 'vscode'
|
||||
import { PreviewEditorProvider } from './previewEditor'
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
@@ -49,7 +50,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<str
|
||||
const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' }
|
||||
const document = { uri, isDirty: dirty, lineAt: (line: number) => ({ text: SOURCE[line] }), lineCount: SOURCE.length, eol: 1 }
|
||||
vi.mocked(workspace.openTextDocument).mockResolvedValue(document as never)
|
||||
const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { subscribe }, rewrite: vi.fn(rewrite) }
|
||||
const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { ask: { subscribe }, rewrite: { subscribe } }, rewrite: vi.fn(rewrite) }
|
||||
const provider = new PreviewEditorProvider(...[
|
||||
{},
|
||||
{},
|
||||
@@ -75,7 +76,7 @@ function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<str
|
||||
|
||||
describe('预览里重写注释', () => {
|
||||
it('点按钮:发技能命令、开新会话,记在这一轮上的就是这句命令;开不了时回一条带 askId 的失败,能再试', async () => {
|
||||
const error = '文件问答模型不可用:official。请重新选择。'
|
||||
const error = '重写注释模型不可用:official。请重新选择。'
|
||||
const editor = preview(async () => error)
|
||||
try {
|
||||
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
|
||||
@@ -139,8 +140,24 @@ describe('预览里重写注释', () => {
|
||||
|
||||
const HEAD_BASE: DiffBase = { label: '上一次提交', key: 'head:web/a.ts', read: async () => '// 改之前', missing: '' }
|
||||
|
||||
const OPTIONS = [{ id: 'offical', label: 'offical' }, { id: 'sonnet', label: 'sonnet' }, { id: 'dsflash', label: 'dsflash' }]
|
||||
|
||||
/** 一套 profile 选择:眼下选的是 selected,publish 模拟它变了、通知订阅的预览 */
|
||||
function profileSet(selected: string) {
|
||||
const listeners = new Set<(choice: ProfileChoice) => void>()
|
||||
return {
|
||||
subscribe: (listener: (choice: ProfileChoice) => void) => {
|
||||
listeners.add(listener)
|
||||
return { dispose: () => listeners.delete(listener) }
|
||||
},
|
||||
choice: async (): Promise<ProfileChoice> => ({ options: OPTIONS, selected }),
|
||||
select: vi.fn(async (_id: string) => {}),
|
||||
publish: (choice: ProfileChoice) => listeners.forEach(listener => listener(choice)),
|
||||
}
|
||||
}
|
||||
|
||||
/** 对比基准只认从「未提交的更改」点开的:从那儿点开的对比上一次提交,别处打开的不比 */
|
||||
function opening() {
|
||||
function opening(profiles = { ask: profileSet(''), rewrite: profileSet('') }) {
|
||||
const subscribe = () => ({ dispose: () => {} })
|
||||
const diffBase = vi.fn((_uri: unknown, from: OpenedFrom): DiffBase | undefined => from === 'uncommitted' ? HEAD_BASE : undefined)
|
||||
const qa = {
|
||||
@@ -148,7 +165,7 @@ function opening() {
|
||||
onDidChange: subscribe,
|
||||
onDidAnswer: subscribe,
|
||||
onDidRewrite: subscribe,
|
||||
profiles: { subscribe, choice: async () => ({ profiles: [], selected: '' }) },
|
||||
profiles,
|
||||
view: () => ({ type: 'qa' }),
|
||||
}
|
||||
const provider = new PreviewEditorProvider(...[
|
||||
@@ -290,3 +307,41 @@ describe('预览对比什么看这个标签页是从哪儿打开的', () => {
|
||||
expect(diffBase).not.toHaveBeenCalledWith(document.uri, 'uncommitted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('问 AI 和重写注释的 profile 下拉各管各的', () => {
|
||||
it('ready 后两套各发一次;哪一套变了只发哪一套', async () => {
|
||||
const profiles = { ask: profileSet('sonnet'), rewrite: profileSet('dsflash') }
|
||||
const { provider, document } = opening(profiles)
|
||||
const page = tab()
|
||||
provider.resolveCustomTextEditor(document as never, page.panel)
|
||||
await page.send({ type: 'ready' })
|
||||
await vi.waitFor(() => {
|
||||
expect(page.posted()).toContainEqual({ type: 'profiles', kind: 'ask', options: OPTIONS, selected: 'sonnet' })
|
||||
expect(page.posted()).toContainEqual({ type: 'profiles', kind: 'rewrite', options: OPTIONS, selected: 'dsflash' })
|
||||
})
|
||||
|
||||
page.clear()
|
||||
profiles.rewrite.publish({ options: OPTIONS, selected: 'offical' })
|
||||
expect(page.posted()).toContainEqual({ type: 'profiles', kind: 'rewrite', options: OPTIONS, selected: 'offical' })
|
||||
expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'profiles', kind: 'ask' }))
|
||||
})
|
||||
|
||||
it('下拉换了哪一套就只改哪一套;换不成时报错,并把那一套眼下的选择发回去', async () => {
|
||||
const profiles = { ask: profileSet('sonnet'), rewrite: profileSet('dsflash') }
|
||||
const { provider, document } = opening(profiles)
|
||||
const page = tab()
|
||||
provider.resolveCustomTextEditor(document as never, page.panel)
|
||||
await page.send({ type: 'ready' })
|
||||
|
||||
await page.send({ type: 'selectProfile', kind: 'rewrite', id: 'offical' })
|
||||
expect(profiles.rewrite.select).toHaveBeenCalledWith('offical')
|
||||
expect(profiles.ask.select).not.toHaveBeenCalled()
|
||||
|
||||
profiles.ask.select.mockRejectedValueOnce(new Error('问答模型不可用:gone。请重新选择。'))
|
||||
page.clear()
|
||||
await page.send({ type: 'selectProfile', kind: 'ask', id: 'gone' })
|
||||
expect(vi.mocked(window.showErrorMessage)).toHaveBeenCalledWith('问答模型不可用:gone。请重新选择。')
|
||||
expect(page.posted()).toContainEqual({ type: 'profiles', kind: 'ask', options: OPTIONS, selected: 'sonnet' })
|
||||
expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'profiles', kind: 'rewrite' }))
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import type { TreeSitterRuntime } from '../runtime'
|
||||
import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks'
|
||||
import type { BlockFilter } from './blockFilter'
|
||||
import type { FoundLocation } from './definitions'
|
||||
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
|
||||
import type { ChatKind, DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
|
||||
import type { TargetSpot } from './openTarget'
|
||||
import type { TopSubject } from './pathPreview'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
@@ -51,6 +51,9 @@ export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
|
||||
/** 连续输入时合并成一次重新解析。 */
|
||||
const REFRESH_DELAY_MS = 150
|
||||
|
||||
/** 预览里各有一个 profile 下拉的两种对话 */
|
||||
const CHAT_KINDS: readonly ChatKind[] = ['ask', 'rewrite']
|
||||
|
||||
/**
|
||||
* 一次打开预览带的话:这个标签页是从哪儿打开的(定对比什么);要滚到哪(从文本编辑器切过来时它顶部那一行,或跳到的定义);
|
||||
* 要不要把光标放进顶部「问 AI」输入框(侧栏「问 AI」打开的)。
|
||||
@@ -290,10 +293,10 @@ class Preview {
|
||||
if (uris.some(uri => uri.toString() === this.subject().uri.toString()))
|
||||
this.postQa()
|
||||
}),
|
||||
owner.qa.profiles.subscribe((choice) => {
|
||||
...CHAT_KINDS.map(kind => owner.qa.profiles[kind].subscribe((choice) => {
|
||||
if (this.ready)
|
||||
this.post({ type: 'askProfiles', ...choice })
|
||||
}),
|
||||
this.post({ type: 'profiles', kind, ...choice })
|
||||
})),
|
||||
owner.qa.onDidAnswer((event) => {
|
||||
if (event.owner !== this.subject().uri.toString() || !this.ready)
|
||||
return
|
||||
@@ -546,11 +549,11 @@ class Preview {
|
||||
this.post({ type: 'focusPathAsk' })
|
||||
}
|
||||
|
||||
/** profile 列表每次现读(同看板),读完页面还在才发。 */
|
||||
private async postProfiles(): Promise<void> {
|
||||
const choice = await this.owner.qa.profiles.choice()
|
||||
/** kind 那种对话的 profile 列表每次现读(同看板),读完页面还在才发。 */
|
||||
private async postProfiles(kind: ChatKind): Promise<void> {
|
||||
const choice = await this.owner.qa.profiles[kind].choice()
|
||||
if (this.ready)
|
||||
this.post({ type: 'askProfiles', ...choice })
|
||||
this.post({ type: 'profiles', kind, ...choice })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -601,7 +604,8 @@ class Preview {
|
||||
this.postHoverDelay()
|
||||
this.postStickyScroll()
|
||||
this.postQa()
|
||||
void this.postProfiles()
|
||||
for (const kind of CHAT_KINDS)
|
||||
void this.postProfiles(kind)
|
||||
await this.push()
|
||||
if (this.pendingPathFocus)
|
||||
this.focusPathAsk()
|
||||
@@ -609,13 +613,13 @@ class Preview {
|
||||
case 'ask':
|
||||
await this.ask(msg.askId, msg.anchor, msg.question, msg.images)
|
||||
return
|
||||
case 'selectAskProfile':
|
||||
case 'selectProfile':
|
||||
try {
|
||||
await this.owner.qa.profiles.select(msg.id)
|
||||
await this.owner.qa.profiles[msg.kind].select(msg.id)
|
||||
}
|
||||
catch (err) {
|
||||
void window.showErrorMessage(err instanceof Error ? err.message : String(err))
|
||||
await this.postProfiles()
|
||||
await this.postProfiles(msg.kind)
|
||||
}
|
||||
return
|
||||
case 'stopAsk':
|
||||
|
||||
Reference in new issue
Block a user