🐛 fix(vscode): 修复看板缓存并增加问答默认模型

This commit is contained in:
cruldra committed 2026-10-09 17:00:43 -04:00
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()
}
})
})
+15 -2
View File
@@ -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 = ''
+1 -1
View File
@@ -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) : []
+120 -30
View File
@@ -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'))
})
})
+43 -26
View File
@@ -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) }
}
}
+136
View File
@@ -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()
})
})
+27 -6
View File
@@ -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()