Skip to content

Commit 2d7f70d

Browse files
Updates AI provider/model selection flow
Improves command naming and UX Updates storage key removals on provider reset
1 parent 8fc6ac9 commit 2d7f70d

File tree

13 files changed

+353
-63
lines changed

13 files changed

+353
-63
lines changed

contributions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4570,7 +4570,7 @@
45704570
}
45714571
},
45724572
"gitlens.switchAIModel": {
4573-
"label": "Switch AI Model",
4573+
"label": "Switch AI Provider/Model",
45744574
"commandPalette": "gitlens:enabled && gitlens:gk:organization:ai:enabled"
45754575
},
45764576
"gitlens.switchMode": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7705,7 +7705,7 @@
77057705
},
77067706
{
77077707
"command": "gitlens.switchAIModel",
7708-
"title": "Switch AI Model",
7708+
"title": "Switch AI Provider/Model",
77097709
"category": "GitLens"
77107710
},
77117711
{

src/constants.ai.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ export const primaryAIProviders = ['gitkraken', 'vscode'] as const satisfies rea
1313

1414
export type AIProviderAndModel = `${string}:${string}`;
1515
export type SupportedAIModels = `${Exclude<AIProviders, AIPrimaryProviders>}:${string}` | AIPrimaryProviders;
16+
17+
export const aiProviderDataDisclaimer =
18+
'GitLens AI features can send code snippets, diffs and other context to your selected AI provider for analysis. This may contain sensitive information.';

src/constants.storage.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const enum SyncedStorageKeys {
2525
}
2626

2727
export type DeprecatedGlobalStorage = {
28-
/** @deprecated use `confirm:ai:tos:${AIProviders}` */
28+
/** @deprecated use `confirm:ai:tos` */
2929
'confirm:sendToOpenAI': boolean;
3030
/** @deprecated */
3131
'home:actions:completed': ('dismissed:welcome' | 'opened:scm')[];
@@ -54,10 +54,14 @@ export type DeprecatedGlobalStorage = {
5454
} & {
5555
/** @deprecated */
5656
[key in `disallow:connection:${string}`]: any;
57+
} & {
58+
/** @deprecated use `confirm:ai:tos` */
59+
[key in `confirm:ai:tos:${AIProviders}`]: boolean;
5760
};
5861

5962
export type GlobalStorage = {
6063
avatars: [string, StoredAvatar][];
64+
'confirm:ai:tos': boolean;
6165
repoVisibility: [string, StoredRepoVisibilityInfo][];
6266
'deepLinks:pending': StoredDeepLinkContext;
6367
pendingWhatsNewOnFocus: boolean;
@@ -82,8 +86,6 @@ export type GlobalStorage = {
8286
'views:scm:grouped:welcome:dismissed': boolean;
8387
'integrations:configured': StoredIntegrationConfigurations;
8488
} & { [key in `plus:preview:${FeaturePreviews}:usages`]: StoredFeaturePreviewUsagePeriod[] } & {
85-
[key in `confirm:ai:tos:${AIProviders}`]: boolean;
86-
} & {
8789
[key in `provider:authentication:skip:${string}`]: boolean;
8890
} & { [key in `gk:${string}:checkin`]: Stored<StoredGKCheckInResponse> } & {
8991
[key in `gk:${string}:organizations`]: Stored<StoredOrganization[]>;
@@ -122,17 +124,21 @@ export interface StoredPromo {
122124
}
123125

124126
export type DeprecatedWorkspaceStorage = {
125-
/** @deprecated use `confirm:ai:tos:${AIProviders}` */
127+
/** @deprecated use `confirm:ai:tos` */
126128
'confirm:sendToOpenAI': boolean;
127129
/** @deprecated */
128130
'graph:banners:dismissed': Record<string, boolean>;
129131
/** @deprecated */
130132
'views:searchAndCompare:keepResults': boolean;
133+
} & {
134+
/** @deprecated use `confirm:ai:tos` */
135+
[key in `confirm:ai:tos:${AIProviders}`]: boolean;
131136
};
132137

133138
export type WorkspaceStorage = {
134139
assumeRepositoriesOnStartup?: boolean;
135140
'branch:comparisons': StoredBranchComparisons;
141+
'confirm:ai:tos': boolean;
136142
'gitComandPalette:usage': StoredRecentUsage;
137143
gitPath: string;
138144
'graph:columns': Record<string, StoredGraphColumn>;
@@ -145,7 +151,7 @@ export type WorkspaceStorage = {
145151
'views:repositories:autoRefresh': boolean;
146152
'views:searchAndCompare:pinned': StoredSearchAndCompareItems;
147153
'views:scm:grouped:selected': GroupableTreeViewTypes;
148-
} & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & {
154+
} & {
149155
[key in `connected:${Integration['key']}`]: boolean;
150156
};
151157

src/plus/ai/aiProviderService.ts

Lines changed: 106 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CancellationToken, Disposable, Event, MessageItem, ProgressOptions } from 'vscode';
2-
import { env, EventEmitter, window } from 'vscode';
2+
import { env, EventEmitter, ThemeIcon, window } from 'vscode';
33
import type { AIPrimaryProviders, AIProviderAndModel, AIProviders, SupportedAIModels } from '../../constants.ai';
4-
import { primaryAIProviders } from '../../constants.ai';
4+
import { aiProviderDataDisclaimer, primaryAIProviders } from '../../constants.ai';
55
import type { AIGenerateDraftEventData, Source, TelemetryEvents } from '../../constants.telemetry';
66
import type { Container } from '../../container';
77
import { CancellationError } from '../../errors';
@@ -13,7 +13,7 @@ import type { GitRevisionReference } from '../../git/models/reference';
1313
import type { Repository } from '../../git/models/repository';
1414
import { uncommitted, uncommittedStaged } from '../../git/models/revision';
1515
import { assertsCommitHasFullDetails } from '../../git/utils/commit.utils';
16-
import { showAIModelPicker } from '../../quickpicks/aiModelPicker';
16+
import { showAIModelPicker, showAIProviderPicker } from '../../quickpicks/aiModelPicker';
1717
import { configuration } from '../../system/-webview/configuration';
1818
import { getContext } from '../../system/-webview/context';
1919
import type { Storage } from '../../system/-webview/storage';
@@ -25,7 +25,7 @@ import { lazy } from '../../system/lazy';
2525
import type { Deferred } from '../../system/promise';
2626
import { getSettledValue } from '../../system/promise';
2727
import type { ServerConnection } from '../gk/serverConnection';
28-
import { ensureFeatureAccess } from '../gk/utils/-webview/acount.utils';
28+
import { ensureAccountQuickPick, ensureFeatureAccess } from '../gk/utils/-webview/acount.utils';
2929
import type { AIActionType, AIModel, AIModelDescriptor } from './models/model';
3030
import type { PromptTemplateContext } from './models/promptTemplates';
3131
import type { AIProvider, AIRequestResult } from './models/provider';
@@ -130,6 +130,14 @@ export class AIProviderService implements Disposable {
130130
return this._provider?.id;
131131
}
132132

133+
get supportedProviders(): readonly AIProviders[] {
134+
return [..._supportedProviderTypes.keys()];
135+
}
136+
137+
get currentModelName(): string | undefined {
138+
return this._model?.name;
139+
}
140+
133141
private getConfiguredModel(): AIModelDescriptor | undefined {
134142
const qualifiedModelId = configuration.get('ai.model') ?? undefined;
135143
if (qualifiedModelId == null) return undefined;
@@ -188,10 +196,48 @@ export class AIProviderService implements Disposable {
188196

189197
if (options?.silent) return undefined;
190198

191-
const pick = await showAIModelPicker(this.container, cfg);
192-
if (pick == null) return undefined;
199+
let chosenProvider: AIProviders | undefined = undefined;
200+
let chosenModel: AIModel | undefined = undefined;
201+
202+
if (!options?.force) {
203+
const vsCodeModels = await this.getModels('vscode');
204+
if (vsCodeModels.length !== 0) {
205+
chosenProvider = 'vscode';
206+
} else if ((await this.container.subscription.getSubscription()).account?.verified) {
207+
chosenProvider = 'gitkraken';
208+
const gitkrakenModels = await this.getModels('gitkraken');
209+
chosenModel = gitkrakenModels.find(m => m.default);
210+
}
211+
}
212+
213+
if (chosenProvider == null) {
214+
chosenProvider = (await showAIProviderPicker(this.container, cfg))?.provider;
215+
if (chosenProvider == null) return;
216+
if (
217+
(chosenProvider === 'gitkraken' ||
218+
(chosenProvider !== 'vscode' &&
219+
(await this.container.storage.getSecret(`gitlens.${chosenProvider}.key`)) == null)) &&
220+
!(await ensureAccountQuickPick(
221+
this.container,
222+
{
223+
label: 'Use AI-powered GitLens features like Generate Commit Message, Explain Commit, and more.',
224+
iconPath: new ThemeIcon('sparkle'),
225+
},
226+
source,
227+
))
228+
) {
229+
return;
230+
}
231+
}
193232

194-
const model = await this.getOrUpdateModel(pick.model);
233+
if (!(await this.ensureProviderConfigured(chosenProvider))) return;
234+
235+
if (chosenModel == null) {
236+
chosenModel = (await showAIModelPicker(this.container, chosenProvider, cfg))?.model;
237+
if (chosenModel == null) return;
238+
}
239+
240+
const model = await this.getOrUpdateModel(chosenModel);
195241

196242
this.container.telemetry.sendEvent(
197243
'ai/switchModel',
@@ -205,9 +251,28 @@ export class AIProviderService implements Disposable {
205251
source,
206252
);
207253

254+
await showAIProviderToS(this.container.storage);
208255
return model;
209256
}
210257

258+
private async ensureProviderConfigured(providerId: AIProviders): Promise<boolean> {
259+
const key = await this.container.storage.getSecret(`gitlens.${providerId}.key`);
260+
if (key != null) return true;
261+
262+
if (this._provider != null && providerId === this._provider.id) return this._provider.ensureConfigured();
263+
const type = await _supportedProviderTypes.get(providerId)?.value;
264+
if (type == null) {
265+
return false;
266+
}
267+
268+
const p = new type(this.container, this.connection);
269+
try {
270+
return await p.ensureConfigured();
271+
} finally {
272+
p.dispose();
273+
}
274+
}
275+
211276
private getOrUpdateModel(model: AIModel): Promise<AIModel | undefined>;
212277
private getOrUpdateModel<T extends AIProviders>(providerId: T, modelId: string): Promise<AIModel | undefined>;
213278
private async getOrUpdateModel(
@@ -547,12 +612,7 @@ export class AIProviderService implements Disposable {
547612
progress?: ProgressOptions;
548613
},
549614
): Promise<AIRequestResult | undefined> {
550-
const { confirmed, model } = await getModelAndConfirmAIProviderToS(
551-
'diff',
552-
source,
553-
this,
554-
this.container.storage,
555-
);
615+
const { confirmed, model } = await getModelAndConfirmAIProviderToS(source, this, this.container.storage);
556616
if (model == null) {
557617
options?.generating?.cancel();
558618
return undefined;
@@ -643,6 +703,11 @@ export class AIProviderService implements Disposable {
643703
return changes;
644704
}
645705

706+
async resetProvider(provider: AIProviders): Promise<void> {
707+
void env.clipboard.writeText((await this.container.storage.getSecret(`gitlens.${provider}.key`)) ?? '');
708+
void this.container.storage.deleteSecret(`gitlens.${provider}.key`);
709+
}
710+
646711
async reset(all?: boolean): Promise<void> {
647712
let { _provider: provider } = this;
648713
if (provider == null) {
@@ -676,11 +741,7 @@ export class AIProviderService implements Disposable {
676741
}
677742

678743
if (provider != null && result === resetCurrent) {
679-
void env.clipboard.writeText((await this.container.storage.getSecret(`gitlens.${provider.id}.key`)) ?? '');
680-
void this.container.storage.deleteSecret(`gitlens.${provider.id}.key`);
681-
682-
void this.container.storage.delete(`confirm:ai:tos:${provider.id}`);
683-
void this.container.storage.deleteWorkspace(`confirm:ai:tos:${provider.id}`);
744+
void this.resetProvider(provider.id);
684745
} else if (result === resetAll) {
685746
const keys = [];
686747
for (const [providerId] of _supportedProviderTypes) {
@@ -706,8 +767,30 @@ export class AIProviderService implements Disposable {
706767
}
707768
}
708769

770+
async function showAIProviderToS(storage: Storage): Promise<void> {
771+
const confirmed = storage.get(`confirm:ai:tos`, false) || storage.getWorkspace(`confirm:ai:tos`, false);
772+
if (confirmed) return;
773+
774+
const acceptWorkspace: MessageItem = { title: 'Always for this Workspace' };
775+
const acceptAlways: MessageItem = { title: 'Always' };
776+
777+
const result = await window.showInformationMessage(
778+
aiProviderDataDisclaimer,
779+
{ modal: true },
780+
acceptWorkspace,
781+
acceptAlways,
782+
);
783+
784+
if (result === acceptWorkspace || result == null) {
785+
void storage.storeWorkspace(`confirm:ai:tos`, true).catch();
786+
}
787+
788+
if (result === acceptAlways) {
789+
void storage.store(`confirm:ai:tos`, true).catch();
790+
}
791+
}
792+
709793
async function getModelAndConfirmAIProviderToS(
710-
confirmationType: 'data' | 'diff',
711794
source: Source,
712795
service: AIProviderService,
713796
storage: Storage,
@@ -716,9 +799,7 @@ async function getModelAndConfirmAIProviderToS(
716799
while (true) {
717800
if (model == null) return { confirmed: false, model: model };
718801

719-
const confirmed =
720-
storage.get(`confirm:ai:tos:${model.provider.id}`, false) ||
721-
storage.getWorkspace(`confirm:ai:tos:${model.provider.id}`, false);
802+
const confirmed = storage.get(`confirm:ai:tos`, false) || storage.getWorkspace(`confirm:ai:tos`, false);
722803
if (confirmed) return { confirmed: true, model: model };
723804

724805
const accept: MessageItem = { title: 'Continue' };
@@ -728,11 +809,7 @@ async function getModelAndConfirmAIProviderToS(
728809
const decline: MessageItem = { title: 'Cancel', isCloseAffordance: true };
729810

730811
const result = await window.showInformationMessage(
731-
`GitLens AI features require sending ${
732-
confirmationType === 'data' ? 'data' : 'a diff of the code changes'
733-
} to ${
734-
model.provider.name
735-
} for analysis. This may contain sensitive information.\n\nDo you want to continue?`,
812+
`${aiProviderDataDisclaimer}\n\nDo you want to continue?`,
736813
{ modal: true },
737814
accept,
738815
switchModel,
@@ -749,12 +826,12 @@ async function getModelAndConfirmAIProviderToS(
749826
if (result === accept) return { confirmed: true, model: model };
750827

751828
if (result === acceptWorkspace) {
752-
void storage.storeWorkspace(`confirm:ai:tos:${model.provider.id}`, true).catch();
829+
void storage.storeWorkspace(`confirm:ai:tos`, true).catch();
753830
return { confirmed: true, model: model };
754831
}
755832

756833
if (result === acceptAlways) {
757-
void storage.store(`confirm:ai:tos:${model.provider.id}`, true).catch();
834+
void storage.store(`confirm:ai:tos`, true).catch();
758835
return { confirmed: true, model: model };
759836
}
760837

src/plus/ai/models/provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface AIProvider<Provider extends AIProviders = AIProviders> extends
2727

2828
onDidChange?: Event<void>;
2929

30+
ensureConfigured(): Promise<boolean>;
3031
getModels(): Promise<readonly AIModel<Provider>[]>;
3132
getPromptTemplate(action: AIActionType, model: AIModel<Provider>): Promise<PromptTemplate | undefined>;
3233

src/plus/ai/openAICompatibleProvider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export abstract class OpenAICompatibleProvider<T extends AIProviders> implements
5959
});
6060
}
6161

62+
async ensureConfigured(): Promise<boolean> {
63+
return (await this.getApiKey()) != null;
64+
}
65+
6266
protected getHeaders<TAction extends AIActionType>(
6367
_action: TAction,
6468
_model: AIModel<T>,

src/plus/ai/vscodeProvider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export class VSCodeAIProvider implements AIProvider<typeof provider.id> {
5454
return models.map(getModelFromChatModel);
5555
}
5656

57+
async ensureConfigured(): Promise<boolean> {
58+
return (await this.getModels()).length !== 0;
59+
}
60+
5761
async getPromptTemplate(action: AIActionType, model: VSCodeAIModel): Promise<PromptTemplate | undefined> {
5862
return Promise.resolve(getLocalPromptTemplate(action, model));
5963
}

0 commit comments

Comments
 (0)