🐛 fix(vscode): 修复看板缓存并增加问答默认模型
This commit is contained in:
1 parent
c38f1dc468
commit
012daf95b5
25 files changed
+1753
-164
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { window } from 'vscode'
|
||||
import { askCommentBody } from '../qa/claudeAsk'
|
||||
import { PreviewEditorProvider } from './previewEditor'
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
commands: { executeCommand: vi.fn() },
|
||||
window: { showErrorMessage: vi.fn() },
|
||||
workspace: {
|
||||
onDidChangeTextDocument: () => ({ dispose: () => {} }),
|
||||
onDidChangeConfiguration: () => ({ dispose: () => {} }),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
|
||||
vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) }))
|
||||
vi.mock('../folderStore', () => ({ locate: () => undefined }))
|
||||
vi.mock('./pathPreview', () => ({ pathPreviewOf: () => undefined }))
|
||||
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
|
||||
vi.mock('../qa/claudeAsk', () => ({ askCommentBody: vi.fn(), askFailure: (error: Error) => error.message }))
|
||||
|
||||
function preview(current: () => Promise<never>) {
|
||||
const subscribe = () => ({ dispose: () => {} })
|
||||
const receive = vi.fn<(listener: (message: WebviewToExtension) => Promise<void>) => { dispose: () => void }>(subscribe)
|
||||
const post = vi.fn<(message: ExtensionToWebview) => void>()
|
||||
const uri = { fsPath: '/workspace/a.ts', scheme: 'file', toString: () => 'file:///workspace/a.ts' }
|
||||
const provider = new PreviewEditorProvider(...[
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{ subscribe },
|
||||
async () => [],
|
||||
{ imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, profiles: { subscribe, current } },
|
||||
async () => [],
|
||||
async () => [],
|
||||
{ onDidChangeDiffBase: subscribe },
|
||||
{ onDidChange: subscribe },
|
||||
async () => false,
|
||||
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
|
||||
provider.resolveCustomTextEditor(...[
|
||||
{ uri, isDirty: false },
|
||||
{ webview: { onDidReceiveMessage: receive, postMessage: post }, onDidChangeViewState: subscribe, onDidDispose: subscribe },
|
||||
] as unknown as Parameters<PreviewEditorProvider['resolveCustomTextEditor']>)
|
||||
const opened = (provider as unknown as { previews: Set<{ sent: unknown, dispose: () => void }> }).previews.values().next().value!
|
||||
opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: {}, startLine: 0, endLine: 1 }] }
|
||||
return { send: receive.mock.calls[0]![0], post, dispose: () => opened.dispose() }
|
||||
}
|
||||
|
||||
describe('重写注释的模型选择失败', () => {
|
||||
it('模型删除后仍发送失败结束消息和错误提示,不调用模型且允许再次尝试', async () => {
|
||||
const error = '文件问答模型不可用:official。请重新选择。'
|
||||
const current = vi.fn<() => Promise<never>>().mockRejectedValue(new Error(error))
|
||||
const editor = preview(current)
|
||||
try {
|
||||
await expect(editor.send({ type: 'rewriteComment', anchor: 'block' })).resolves.toBeUndefined()
|
||||
expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', anchor: 'block', status: 'failed', error })
|
||||
expect(window.showErrorMessage).toHaveBeenCalledWith(`代码阅读:${error}`)
|
||||
expect(askCommentBody).not.toHaveBeenCalled()
|
||||
await editor.send({ type: 'rewriteComment', anchor: 'block' })
|
||||
expect(current).toHaveBeenCalledTimes(2)
|
||||
expect(editor.post.mock.calls.filter(([message]) => message.type === 'rewriteEnd')).toHaveLength(2)
|
||||
}
|
||||
finally {
|
||||
editor.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
|
||||
import type { ClaudeProfile } from '../../cc/profiles'
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { CommentEdit, RewriteOutcome, WriteResult } from '../commentRewrite'
|
||||
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange, WorkspaceIndex } from '../contract'
|
||||
@@ -547,7 +548,13 @@ class Preview {
|
||||
await this.ask(msg.askId, msg.anchor, msg.question, msg.images)
|
||||
return
|
||||
case 'selectAskProfile':
|
||||
await this.owner.qa.profiles.select(msg.id)
|
||||
try {
|
||||
await this.owner.qa.profiles.select(msg.id)
|
||||
}
|
||||
catch (err) {
|
||||
void window.showErrorMessage(err instanceof Error ? err.message : String(err))
|
||||
await this.postProfiles()
|
||||
}
|
||||
return
|
||||
case 'stopAsk':
|
||||
this.owner.qa.stop(msg.askId)
|
||||
@@ -968,7 +975,13 @@ class Preview {
|
||||
return failed('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。')
|
||||
const at = locate(document.uri)
|
||||
const cwd = at?.folder.uri.fsPath ?? dirname(document.uri.fsPath)
|
||||
const profile = await this.owner.qa.profiles.current()
|
||||
let profile: ClaudeProfile | undefined
|
||||
try {
|
||||
profile = await this.owner.qa.profiles.current()
|
||||
}
|
||||
catch (err) {
|
||||
return failed(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
if (signal.aborted)
|
||||
return { status: 'stopped' }
|
||||
let question = ''
|
||||
|
||||
@@ -86,7 +86,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
return runtime.parse(adapter, languageId, source)
|
||||
}, report))
|
||||
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => resolveReference(reference, registry.libraryRules(), indexes, report)
|
||||
const qa = new CodeQa(context.globalState, context.workspaceState)
|
||||
const qa = new CodeQa(context, context.workspaceState)
|
||||
const namesIn = async (languageId: string, name: string): Promise<IndexedName[]> => {
|
||||
const adapter = registry.languageFor(languageId)
|
||||
return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : []
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Memento } from 'vscode'
|
||||
import type { ClaudeProfile } from '../../cc/profiles'
|
||||
import type { ProfileChoice, ProfileSource } from './askProfiles'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ASK_PROFILE_KEY, AskProfiles, DEFAULT_OPTION, profileChoice } from './askProfiles'
|
||||
import { AskProfiles, DEFAULT_OPTION, profileChoice } from './askProfiles'
|
||||
import { askArgs } from './claudeAsk'
|
||||
|
||||
const DIR = '/home/me/profiles'
|
||||
@@ -10,19 +9,8 @@ const profile = (name: string): ClaudeProfile => ({ name, path: `${DIR}/${name}.
|
||||
const PROFILES = [profile('dsflash'), profile('offical'), profile('sonnet')]
|
||||
const DEFAULT_PATH = `${DIR}/offical.json`
|
||||
|
||||
function memento(initial: Record<string, unknown> = {}): Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> } {
|
||||
const store = { ...initial }
|
||||
return {
|
||||
store,
|
||||
get: <T>(key: string): T | undefined => store[key] as T | undefined,
|
||||
update: async (key: string, value: unknown) => {
|
||||
store[key] = value
|
||||
},
|
||||
} as Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> }
|
||||
}
|
||||
|
||||
function source(profiles: ClaudeProfile[] = PROFILES): ProfileSource {
|
||||
return { list: async () => profiles, defaultPath: () => DEFAULT_PATH }
|
||||
return { list: async () => profiles, defaultPath: () => DEFAULT_PATH, directory: () => DIR }
|
||||
}
|
||||
|
||||
describe('profile 列表 → 下拉的选项', () => {
|
||||
@@ -43,27 +31,129 @@ describe('profile 列表 → 下拉的选项', () => {
|
||||
})
|
||||
|
||||
it('一个 profile 都没有:只有「默认」', () => {
|
||||
expect(profileChoice([], DEFAULT_PATH, 'sonnet')).toEqual({ options: [DEFAULT_OPTION], selected: '' })
|
||||
expect(profileChoice([], DEFAULT_PATH, undefined)).toEqual({ options: [DEFAULT_OPTION], selected: '' })
|
||||
})
|
||||
|
||||
it('选过的还在就选它;不在了(删了、目录换了)、记的是「默认」但已经有默认 profile,都回到默认那项', () => {
|
||||
expect(profileChoice(PROFILES, DEFAULT_PATH, 'sonnet').selected).toBe('sonnet')
|
||||
expect(profileChoice(PROFILES, DEFAULT_PATH, 'gone').selected).toBe('offical')
|
||||
expect(profileChoice(PROFILES, DEFAULT_PATH, '').selected).toBe('offical')
|
||||
expect(profileChoice(PROFILES, DEFAULT_PATH, 42).selected).toBe('offical')
|
||||
it('临时选项保留;失效选项明确标记,不偷偷退回默认', () => {
|
||||
expect(profileChoice(PROFILES, DEFAULT_PATH, `${DIR}/sonnet.json`).selected).toBe('sonnet')
|
||||
const gone = `${DIR}/gone.json`
|
||||
expect(profileChoice(PROFILES, DEFAULT_PATH, gone)).toMatchObject({ selected: gone, options: expect.arrayContaining([{ id: gone, label: `不可用:${gone}` }]) })
|
||||
expect(profileChoice(PROFILES, DEFAULT_PATH, '').selected).toBe('')
|
||||
expect(profileChoice(PROFILES, DEFAULT_PATH, '').options).toContainEqual(DEFAULT_OPTION)
|
||||
})
|
||||
})
|
||||
|
||||
describe('记住最近选的 profile', () => {
|
||||
it('选了记进 globalState,换个预览、重启 VS Code(新的实例)读得回来', async () => {
|
||||
const state = memento()
|
||||
await new AskProfiles(state, source()).select('sonnet')
|
||||
expect(state.store[ASK_PROFILE_KEY]).toBe('sonnet')
|
||||
expect((await new AskProfiles(state, source()).choice()).selected).toBe('sonnet')
|
||||
describe('文件问答默认模型与临时选择', () => {
|
||||
it.each(['offical', 'official'])('临时选空默认后,%s.json 增加或删除都保持不带配置文件', async (officialName) => {
|
||||
let available = [profile('sonnet')]
|
||||
let defaultPath = DEFAULT_PATH
|
||||
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, {
|
||||
...source(),
|
||||
list: async () => available,
|
||||
defaultPath: () => defaultPath,
|
||||
})
|
||||
await profiles.select('')
|
||||
for (const added of [true, false, true]) {
|
||||
available = added ? [profile('sonnet'), profile(officialName)] : [profile('sonnet')]
|
||||
defaultPath = added ? `${DIR}/${officialName}.json` : DEFAULT_PATH
|
||||
await profiles.refresh()
|
||||
const choice = await profiles.choice()
|
||||
expect(choice.selected).toBe('')
|
||||
expect(choice.options).toContainEqual(DEFAULT_OPTION)
|
||||
expect(await profiles.current()).toBeUndefined()
|
||||
expect(askArgs({ profilePath: (await profiles.current())?.path })).not.toContain('--settings')
|
||||
}
|
||||
})
|
||||
|
||||
it('真实目录变更才清除显式空选择并恢复设置默认', async () => {
|
||||
let directory = DIR
|
||||
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, {
|
||||
...source([profile('sonnet')]),
|
||||
directory: () => directory,
|
||||
})
|
||||
await profiles.select('')
|
||||
expect(await profiles.current()).toBeUndefined()
|
||||
directory = '/new/profiles'
|
||||
await profiles.refresh()
|
||||
expect(await profiles.current()).toEqual(profile('sonnet'))
|
||||
})
|
||||
|
||||
it('仅官方文件删除改变自动默认路径时,保留失效的临时选择并阻止发问', async () => {
|
||||
let available = [profile('dsflash'), profile('official')]
|
||||
let defaultPath = `${DIR}/official.json`
|
||||
const profiles = new AskProfiles(() => `${DIR}/dsflash.json`, {
|
||||
...source(),
|
||||
list: async () => available,
|
||||
defaultPath: () => defaultPath,
|
||||
})
|
||||
await profiles.select('official')
|
||||
available = [profile('dsflash')]
|
||||
defaultPath = `${DIR}/offical.json`
|
||||
expect((await profiles.choice()).selected).toBe(`${DIR}/official.json`)
|
||||
await expect(profiles.current()).rejects.toThrow(`文件问答模型不可用:${DIR}/official.json`)
|
||||
})
|
||||
|
||||
it('没有官方配置文件时,可以从配置的默认模型临时切到内置默认', async () => {
|
||||
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, source([profile('sonnet')]))
|
||||
await profiles.select('')
|
||||
expect((await profiles.choice()).selected).toBe('')
|
||||
expect(await profiles.current()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('配置默认实际用于命令行;临时切换不改默认,重新创建服务恢复配置', async () => {
|
||||
const configured = `${DIR}/dsflash.json`
|
||||
const profiles = new AskProfiles(() => configured, source())
|
||||
expect(await profiles.current()).toEqual(profile('dsflash'))
|
||||
expect(askArgs({ profilePath: (await profiles.current())?.path }).slice(0, 2)).toEqual(['--settings', configured])
|
||||
await profiles.select('sonnet')
|
||||
expect((await profiles.choice()).selected).toBe('sonnet')
|
||||
expect(await profiles.current()).toEqual(profile('sonnet'))
|
||||
expect(configured).toBe(`${DIR}/dsflash.json`)
|
||||
expect(await new AskProfiles(() => configured, source()).current()).toEqual(profile('dsflash'))
|
||||
})
|
||||
|
||||
it('配置变更清除临时选择并通知所有预览;相同配置刷新保留临时选择', async () => {
|
||||
let configured = `${DIR}/dsflash.json`
|
||||
const profiles = new AskProfiles(() => configured, source())
|
||||
const updates: string[] = []
|
||||
profiles.subscribe(choice => updates.push(choice.selected))
|
||||
await profiles.select('sonnet')
|
||||
await profiles.refresh()
|
||||
expect((await profiles.choice()).selected).toBe('sonnet')
|
||||
configured = DEFAULT_PATH
|
||||
await profiles.refresh()
|
||||
expect(await profiles.current()).toEqual(profile('offical'))
|
||||
expect(updates.at(-1)).toBe('offical')
|
||||
configured = ''
|
||||
await profiles.refresh()
|
||||
expect((await profiles.choice()).selected).toBe('offical')
|
||||
})
|
||||
|
||||
it('配置的模型已删除时保留不可用选项并报错,不会偷偷换成官方模型', async () => {
|
||||
let available = [...PROFILES]
|
||||
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, { ...source(), list: async () => available })
|
||||
expect(await profiles.current()).toEqual(profile('sonnet'))
|
||||
available = available.filter(item => item.name !== 'sonnet')
|
||||
const choice = await profiles.choice()
|
||||
expect(choice.options.find(item => item.id === choice.selected)?.label).toContain('不可用')
|
||||
await expect(profiles.current()).rejects.toThrow('文件问答模型不可用')
|
||||
await profiles.select('dsflash')
|
||||
expect(await profiles.current()).toEqual(profile('dsflash'))
|
||||
})
|
||||
|
||||
it('临时模型被删除和无效选择都会报错,不能静默换模型', async () => {
|
||||
let available = [...PROFILES]
|
||||
const profiles = new AskProfiles(() => '', { ...source(), list: async () => available })
|
||||
await profiles.select('sonnet')
|
||||
available = available.filter(item => item.name !== 'sonnet')
|
||||
await expect(profiles.current()).rejects.toThrow('文件问答模型不可用')
|
||||
await expect(profiles.select('unknown')).rejects.toThrow('文件问答模型不可用')
|
||||
await profiles.select('offical')
|
||||
expect(await profiles.current()).toEqual(profile('offical'))
|
||||
})
|
||||
|
||||
it('一个预览选了,所有订阅的预览都收到新的选项和选中的那项;取消订阅后不再收到', async () => {
|
||||
const profiles = new AskProfiles(memento(), source())
|
||||
const profiles = new AskProfiles(() => '', source())
|
||||
const first: ProfileChoice[] = []
|
||||
const second: string[] = []
|
||||
profiles.subscribe(choice => first.push(choice))
|
||||
@@ -77,17 +167,17 @@ describe('记住最近选的 profile', () => {
|
||||
})
|
||||
|
||||
it('下一个问题用选的那个 profile:命令行带它的 --settings;选「默认」时没有、不带 --settings', async () => {
|
||||
const profiles = new AskProfiles(memento({ [ASK_PROFILE_KEY]: 'sonnet' }), source())
|
||||
const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, source())
|
||||
const chosen = await profiles.current()
|
||||
expect(chosen).toEqual(profile('sonnet'))
|
||||
expect(askArgs({ profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`])
|
||||
|
||||
const none = await new AskProfiles(memento(), source([])).current()
|
||||
const none = await new AskProfiles(() => '', source([])).current()
|
||||
expect(none).toBeUndefined()
|
||||
expect(askArgs({ profilePath: none?.path })).not.toContain('--settings')
|
||||
})
|
||||
|
||||
it('没选过:用插件的默认 profile(和以前问 AI 用的一样)', async () => {
|
||||
expect(await new AskProfiles(memento(), source()).current()).toEqual(profile('offical'))
|
||||
expect(await new AskProfiles(() => '', source()).current()).toEqual(profile('offical'))
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { Disposable, Memento } from 'vscode'
|
||||
import type { Disposable } from 'vscode'
|
||||
import type { ClaudeProfile } from '../../cc/profiles'
|
||||
import { getDefaultProfilePath, listClaudeProfiles } from '../../cc/profiles'
|
||||
|
||||
/** globalState 里记的「问 AI」最近选的 profile 名(「默认」记成 '')。 */
|
||||
export const ASK_PROFILE_KEY = 'superpowers.codeReader.askProfile'
|
||||
import { getDefaultProfilePath, getProfilesDir, listClaudeProfiles } from '../../cc/profiles'
|
||||
|
||||
/** 下拉里的一项:id 是 profile 名;「默认」(不带 `--settings`,claude 用 ~/.claude 的登录)的 id 是 ''。 */
|
||||
export interface ProfileOption {
|
||||
@@ -18,60 +15,80 @@ export interface ProfileChoice {
|
||||
|
||||
export const DEFAULT_OPTION: ProfileOption = { id: '', label: '默认' }
|
||||
|
||||
/**
|
||||
* 下拉的选项和选中的那项。插件的默认 profile(getDefaultProfilePath)在列表里时,没选过就选它;
|
||||
* 不在列表里(一个 profile 都没有,或没有 offical.json)时最前面加一项「默认」,没选过就选它。
|
||||
* 记着的那项不在列表里了(profile 删了、目录换了)也回到这一项。
|
||||
*/
|
||||
export function profileChoice(profiles: readonly ClaudeProfile[], defaultPath: string, remembered: unknown): ProfileChoice {
|
||||
/** 下拉显示临时选择或设置默认值,失效的选择明确标出,避免回答时偷偷换模型。 */
|
||||
export function profileChoice(profiles: readonly ClaudeProfile[], defaultPath: string, temporaryPath: string | undefined, configuredPath = ''): ProfileChoice {
|
||||
const fallback = profiles.find(profile => profile.path === defaultPath)
|
||||
const options = [...(fallback ? [] : [DEFAULT_OPTION]), ...profiles.map(({ name }) => ({ id: name, label: name }))]
|
||||
const selected = options.find(option => option.id === remembered)?.id ?? fallback?.name ?? DEFAULT_OPTION.id
|
||||
const requestedPath = temporaryPath ?? (configuredPath || fallback?.path)
|
||||
const selected = profiles.find(profile => profile.path === requestedPath)?.name ?? requestedPath ?? DEFAULT_OPTION.id
|
||||
const options = [...(!fallback || selected === '' ? [DEFAULT_OPTION] : []), ...profiles.map(({ name }) => ({ id: name, label: name }))]
|
||||
if (!options.some(option => option.id === selected))
|
||||
options.push({ id: selected, label: `不可用:${selected}` })
|
||||
return { options, selected }
|
||||
}
|
||||
|
||||
export interface ProfileSource {
|
||||
list: () => Promise<ClaudeProfile[]>
|
||||
defaultPath: () => string
|
||||
directory: () => string
|
||||
}
|
||||
|
||||
const PROFILE_SOURCE: ProfileSource = { list: listClaudeProfiles, defaultPath: getDefaultProfilePath }
|
||||
const PROFILE_SOURCE: ProfileSource = { list: listClaudeProfiles, defaultPath: getDefaultProfilePath, directory: getProfilesDir }
|
||||
|
||||
/**
|
||||
* 「问 AI」用哪个 Claude Code profile:所有预览共用一份,记在 globalState 里,换个预览、重启 VS Code 都还是它;
|
||||
* 一个预览改了,所有订阅的预览都收到(包括改的那个,webview 只按扩展端发来的显示)。
|
||||
* profile 列表每次现读(同看板:cc/profiles 的目录),不依赖 vscode 运行时,便于单元测试。
|
||||
*/
|
||||
/** 所有预览共用临时模型选择,重新创建服务或修改默认配置后恢复设置里的默认值。 */
|
||||
export class AskProfiles {
|
||||
private readonly listeners = new Set<(choice: ProfileChoice) => void>()
|
||||
private temporary: string | undefined
|
||||
private defaultKey: string | undefined
|
||||
|
||||
constructor(private readonly state: Pick<Memento, 'get' | 'update'>, private readonly source: ProfileSource = PROFILE_SOURCE) {}
|
||||
constructor(private readonly configuredPath: () => string, private readonly source: ProfileSource = PROFILE_SOURCE) {}
|
||||
|
||||
async choice(): Promise<ProfileChoice> {
|
||||
return (await this.read()).choice
|
||||
}
|
||||
|
||||
/** 下一个问题用的 profile;选的是「默认」时没有(不带 `--settings`)。 */
|
||||
/** 下一个问题使用当前显示的模型,失效时直接报错而不换成其他模型。 */
|
||||
async current(): Promise<ClaudeProfile | undefined> {
|
||||
const { profiles, choice } = await this.read()
|
||||
return profiles.find(profile => profile.name === choice.selected)
|
||||
this.publish(choice)
|
||||
const profile = profiles.find(profile => profile.name === choice.selected)
|
||||
if (choice.selected && !profile)
|
||||
throw new Error(`文件问答模型不可用:${choice.selected}。请在设置中重新选择默认模型,或在输入框旁临时切换。`)
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 选了就调 listener,带上新的选项和选中的那项。 */
|
||||
/** 选择变化时通知所有打开的预览,让显示和实际发送使用同一个模型。 */
|
||||
subscribe(listener: (choice: ProfileChoice) => void): Disposable {
|
||||
this.listeners.add(listener)
|
||||
return { dispose: () => this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
async select(id: string): Promise<void> {
|
||||
await this.state.update(ASK_PROFILE_KEY, id)
|
||||
const choice = await this.choice()
|
||||
const { profiles, choice } = await this.read()
|
||||
const profile = profiles.find(profile => profile.name === id)
|
||||
if (id ? !profile : !choice.options.some(option => option.id === ''))
|
||||
throw new Error(`文件问答模型不可用:${id}。请重新选择。`)
|
||||
this.temporary = profile?.path ?? ''
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
this.publish(await this.choice())
|
||||
}
|
||||
|
||||
private publish(choice: ProfileChoice): void {
|
||||
for (const listener of [...this.listeners])
|
||||
listener(choice)
|
||||
}
|
||||
|
||||
private async read(): Promise<{ profiles: ClaudeProfile[], choice: ProfileChoice }> {
|
||||
const profiles = await this.source.list()
|
||||
return { profiles, choice: profileChoice(profiles, this.source.defaultPath(), this.state.get(ASK_PROFILE_KEY)) }
|
||||
const configuredPath = this.configuredPath().trim()
|
||||
const defaultPath = this.source.defaultPath()
|
||||
const key = JSON.stringify([configuredPath, this.source.directory()])
|
||||
if (key !== this.defaultKey) {
|
||||
this.defaultKey = key
|
||||
this.temporary = undefined
|
||||
}
|
||||
return { profiles, choice: profileChoice(profiles, defaultPath, this.temporary, configuredPath) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ExtensionContext, Memento, Uri } from 'vscode'
|
||||
import type { AskRunner } from './claudeAsk'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listClaudeProfiles } from '../../cc/profiles'
|
||||
import { getSettings, saveSettings } from '../../settings/store'
|
||||
import { CodeQa } from './codeQa'
|
||||
|
||||
vi.mock('vscode', async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
return {
|
||||
EventEmitter: class {
|
||||
private readonly emitter = new EventEmitter()
|
||||
event = (listener: (value: unknown) => void) => {
|
||||
this.emitter.on('event', listener)
|
||||
return { dispose: () => this.emitter.off('event', listener) }
|
||||
}
|
||||
|
||||
fire(value: unknown) { this.emitter.emit('event', value) }
|
||||
dispose() { this.emitter.removeAllListeners() }
|
||||
},
|
||||
Disposable: { from: () => ({ dispose: () => {} }) },
|
||||
Uri: {},
|
||||
window: { showErrorMessage: vi.fn() },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../folderStore', () => ({
|
||||
FolderStore: class {
|
||||
get() { return {} }
|
||||
dispose() {}
|
||||
},
|
||||
locate: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
|
||||
}))
|
||||
vi.mock('../editor/pathPreview', () => ({
|
||||
pathQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
|
||||
}))
|
||||
vi.mock('./pathAskDescribe', () => ({ describePath: async () => ({ kind: 'file', relativePath: 'a.ts', head: { unavailable: '测试' } }) }))
|
||||
vi.mock('../../cc/profiles', () => ({
|
||||
getDefaultProfilePath: () => '/p/offical.json',
|
||||
getProfilesDir: () => '/p',
|
||||
listClaudeProfiles: vi.fn(),
|
||||
}))
|
||||
|
||||
const AVAILABLE = ['offical', 'sonnet', 'dsflash'].map(name => ({ name, path: `/p/${name}.json` }))
|
||||
const uri = { fsPath: '/workspace/a.ts', toString: () => 'file:///workspace/a.ts' } as Uri
|
||||
const dirs: string[] = []
|
||||
const services: CodeQa[] = []
|
||||
|
||||
function context(): ExtensionContext {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'file-qa-settings-'))
|
||||
dirs.push(dir)
|
||||
return {
|
||||
extensionPath: path.resolve(__dirname, '../../..'),
|
||||
globalStorageUri: { fsPath: dir },
|
||||
globalState: { get: (key: string) => key === 'superpowers.codeReader.askProfile' ? 'sonnet' : undefined },
|
||||
workspaceState: { get: () => undefined, update: vi.fn() },
|
||||
} as unknown as ExtensionContext
|
||||
}
|
||||
|
||||
function service(ctx: ExtensionContext) {
|
||||
const runner = vi.fn<AskRunner>(run => new Promise((_resolve, reject) => {
|
||||
run.signal.addEventListener('abort', () => reject(new Error('停止')))
|
||||
}))
|
||||
const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner)
|
||||
services.push(qa)
|
||||
return { qa, runner }
|
||||
}
|
||||
|
||||
beforeEach(() => vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE))
|
||||
afterEach(() => {
|
||||
services.splice(0).forEach(qa => qa.dispose())
|
||||
dirs.splice(0).forEach(dir => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
describe('问这个文件实际使用设置的默认模型', () => {
|
||||
it('新增官方文件和保存无关设置不改变显式空选择,真实默认变更才重置', async () => {
|
||||
const ctx = context()
|
||||
await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json' })
|
||||
vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE.filter(item => item.name !== 'offical'))
|
||||
const { qa, runner } = service(ctx)
|
||||
await qa.profiles.select('')
|
||||
vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE)
|
||||
const shown = vi.fn()
|
||||
qa.profiles.subscribe(shown)
|
||||
await saveSettings(ctx, { webhookPort: 9000 })
|
||||
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: '' })))
|
||||
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
|
||||
expect(runner.mock.calls[0]?.[0]).not.toHaveProperty('profilePath')
|
||||
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/sonnet.json')
|
||||
qa.stop('1')
|
||||
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
|
||||
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
|
||||
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
|
||||
expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/dsflash.json')
|
||||
})
|
||||
|
||||
it('忽略旧选择,临时切换不写默认;重新创建后恢复配置', async () => {
|
||||
const ctx = context()
|
||||
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
|
||||
const { qa, runner } = service(ctx)
|
||||
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
|
||||
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
|
||||
qa.stop('1')
|
||||
await qa.profiles.select('sonnet')
|
||||
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
|
||||
expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/sonnet.json')
|
||||
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/dsflash.json')
|
||||
const restarted = service(ctx)
|
||||
await restarted.qa.askPath(uri, 'file', { askId: '3', question: '这是什么', images: [] })
|
||||
expect(restarted.runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
|
||||
})
|
||||
|
||||
it('保存新默认后所有现有预览收到新选择,下一个问题和界面使用同一个模型', async () => {
|
||||
const ctx = context()
|
||||
const { qa, runner } = service(ctx)
|
||||
await qa.profiles.select('sonnet')
|
||||
const shown = vi.fn()
|
||||
qa.profiles.subscribe(shown)
|
||||
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
|
||||
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
|
||||
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
|
||||
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
|
||||
})
|
||||
|
||||
it('已删除配置以现有问答错误返回,不能启动模型;代码块问答同样阻止', async () => {
|
||||
const ctx = context()
|
||||
await saveSettings(ctx, { defaultAskProfilePath: '/p/deleted.json' })
|
||||
const { qa, runner } = service(ctx)
|
||||
expect(await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })).toContain('文件问答模型不可用')
|
||||
expect(await qa.ask(uri, { askId: '2', anchor: 'a', question: '问题', prompt: '问题', followUp: '继续', images: [] })).toContain('文件问答模型不可用')
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Event, Memento } from 'vscode'
|
||||
import type { Event, ExtensionContext, Memento } from 'vscode'
|
||||
import type { ClaudeProfile } from '../../cc/profiles'
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { QaMessage } from '../editor/messages'
|
||||
import type { PathSubject } from '../editor/pathPreview'
|
||||
@@ -7,6 +8,7 @@ import type { AskRunner } from './claudeAsk'
|
||||
import type { QaEntry } from './qaMap'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Disposable, EventEmitter, Uri, window } from 'vscode'
|
||||
import { getSettings, onDidChangeSettings } from '../../settings/store'
|
||||
import { pathQaLocation } from '../editor/pathPreview'
|
||||
import { FolderStore, locate } from '../folderStore'
|
||||
import { AskProfiles } from './askProfiles'
|
||||
@@ -47,7 +49,8 @@ export class CodeQa implements Disposable {
|
||||
private readonly pathChanged = new EventEmitter<Uri[]>()
|
||||
/** 顶部问答的读写排成一队,两次同时答完不会互相盖掉 */
|
||||
private pathQueue: Promise<void> = Promise.resolve()
|
||||
/** 问的时候用哪个 profile(所有预览共用,记在 globalState 里) */
|
||||
private readonly settingsSubscription: Disposable
|
||||
/** 所有预览共用当前模型,临时切换不会写入默认设置。 */
|
||||
readonly profiles: AskProfiles
|
||||
/** 块下面或顶部的问答变了的那些文件 / 文件夹 */
|
||||
readonly onDidChange: Event<Uri[]> = (listener, thisArgs, disposables) => {
|
||||
@@ -62,11 +65,16 @@ export class CodeQa implements Disposable {
|
||||
readonly onDidAnswer = this.answered.event
|
||||
|
||||
constructor(
|
||||
profilesState: Pick<Memento, 'get' | 'update'>,
|
||||
context: ExtensionContext,
|
||||
private readonly paths: Pick<Memento, 'get' | 'update'>,
|
||||
runner: AskRunner = askClaude,
|
||||
) {
|
||||
this.profiles = new AskProfiles(profilesState)
|
||||
this.profiles = new AskProfiles(() => getSettings(context).defaultAskProfilePath)
|
||||
this.settingsSubscription = onDidChangeSettings(context, () => {
|
||||
void this.profiles.refresh().catch((err) => {
|
||||
void window.showErrorMessage(`文件问答模型刷新失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
})
|
||||
this.asks = new QaAsks(runner)
|
||||
}
|
||||
|
||||
@@ -118,7 +126,13 @@ export class CodeQa implements Disposable {
|
||||
const { askId, anchor, question, prompt, followUp, images } = request
|
||||
if (!images.every(image => isQaImageType(image.mediaType)))
|
||||
return '只能带 PNG、JPEG、GIF、WebP 图片。'
|
||||
const profile = await this.profiles.current()
|
||||
let profile: ClaudeProfile | undefined
|
||||
try {
|
||||
profile = await this.profiles.current()
|
||||
}
|
||||
catch (err) {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
const owner = uri.toString()
|
||||
const sessionId = latestSession(this.store.get(at.folder, at.key), anchor)
|
||||
const run = {
|
||||
@@ -173,7 +187,13 @@ export class CodeQa implements Disposable {
|
||||
if (!images.every(image => isQaImageType(image.mediaType)))
|
||||
return '只能带 PNG、JPEG、GIF、WebP 图片。'
|
||||
const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key })
|
||||
const profile = await this.profiles.current()
|
||||
let profile: ClaudeProfile | undefined
|
||||
try {
|
||||
profile = await this.profiles.current()
|
||||
}
|
||||
catch (err) {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
const owner = uri.toString()
|
||||
const key = pathQaKey(at.folder.uri.toString(), at.key)
|
||||
const sessionId = pathQaEntries(this.pathMap(), key).at(-1)?.sessionId
|
||||
@@ -242,6 +262,7 @@ export class CodeQa implements Disposable {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.settingsSubscription.dispose()
|
||||
this.asks.stopAll()
|
||||
this.answered.dispose()
|
||||
this.pathChanged.dispose()
|
||||
|
||||
Reference in new issue
Block a user