Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { coalesce, isNonEmptyArray } from '../../../../base/common/arrays.js';
7
import { Codicon } from '../../../../base/common/codicons.js';
8
import { toErrorMessage } from '../../../../base/common/errorMessage.js';
9
import { Event } from '../../../../base/common/event.js';
10
import { MarkdownString } from '../../../../base/common/htmlContent.js';
11
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
12
import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js';
13
import * as strings from '../../../../base/common/strings.js';
14
import { localize, localize2 } from '../../../../nls.js';
15
import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
16
import { ExtensionIdentifier, IExtensionManifest } from '../../../../platform/extensions/common/extensions.js';
17
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
18
import { IProductService } from '../../../../platform/product/common/productService.js';
19
import { Registry } from '../../../../platform/registry/common/platform.js';
20
import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';
21
import { IWorkbenchContribution } from '../../../common/contributions.js';
22
import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js';
23
import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../services/extensionManagement/common/extensionFeatures.js';
24
import { isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';
25
import * as extensionsRegistry from '../../../services/extensions/common/extensionsRegistry.js';
26
import { showExtensionsWithIdsCommandId } from '../../extensions/browser/extensionsActions.js';
27
import { IExtension, IExtensionsWorkbenchService } from '../../extensions/common/extensions.js';
28
import { IChatAgentData, IChatAgentService } from '../common/chatAgents.js';
29
import { ChatContextKeys } from '../common/chatContextKeys.js';
30
import { IRawChatParticipantContribution } from '../common/chatParticipantContribTypes.js';
31
import { ChatAgentLocation, ChatModeKind } from '../common/constants.js';
32
import { ChatViewId } from './chat.js';
33
import { CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './chatViewPane.js';
34
35
// --- Chat Container & View Registration
36
37
const chatViewContainer: ViewContainer = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).registerViewContainer({
38
id: CHAT_SIDEBAR_PANEL_ID,
39
title: localize2('chat.viewContainer.label', "Chat"),
40
icon: Codicon.chatSparkle,
41
ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [CHAT_SIDEBAR_PANEL_ID, { mergeViewWithContainerWhenSingleView: true }]),
42
storageId: CHAT_SIDEBAR_PANEL_ID,
43
hideIfEmpty: true,
44
order: 1,
45
}, ViewContainerLocation.AuxiliaryBar, { isDefault: true, doNotRegisterOpenCommand: true });
46
47
const chatViewDescriptor: IViewDescriptor[] = [{
48
id: ChatViewId,
49
containerIcon: chatViewContainer.icon,
50
containerTitle: chatViewContainer.title.value,
51
singleViewPaneContainerTitle: chatViewContainer.title.value,
52
name: localize2('chat.viewContainer.label', "Chat"),
53
canToggleVisibility: false,
54
canMoveView: true,
55
openCommandActionDescriptor: {
56
id: CHAT_SIDEBAR_PANEL_ID,
57
title: chatViewContainer.title,
58
mnemonicTitle: localize({ key: 'miToggleChat', comment: ['&& denotes a mnemonic'] }, "&&Chat"),
59
keybindings: {
60
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI,
61
mac: {
62
primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI
63
}
64
},
65
order: 1
66
},
67
ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ location: ChatAgentLocation.Panel }]),
68
when: ContextKeyExpr.or(
69
ContextKeyExpr.or(
70
ChatContextKeys.Setup.hidden,
71
ChatContextKeys.Setup.disabled
72
)?.negate(),
73
ChatContextKeys.panelParticipantRegistered,
74
ChatContextKeys.extensionInvalid
75
)
76
}];
77
Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews(chatViewDescriptor, chatViewContainer);
78
79
const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatParticipantContribution[]>({
80
extensionPoint: 'chatParticipants',
81
jsonSchema: {
82
description: localize('vscode.extension.contributes.chatParticipant', 'Contributes a chat participant'),
83
type: 'array',
84
items: {
85
additionalProperties: false,
86
type: 'object',
87
defaultSnippets: [{ body: { name: '', description: '' } }],
88
required: ['name', 'id'],
89
properties: {
90
id: {
91
description: localize('chatParticipantId', "A unique id for this chat participant."),
92
type: 'string'
93
},
94
name: {
95
description: localize('chatParticipantName', "User-facing name for this chat participant. The user will use '@' with this name to invoke the participant. Name must not contain whitespace."),
96
type: 'string',
97
pattern: '^[\\w-]+$'
98
},
99
fullName: {
100
markdownDescription: localize('chatParticipantFullName', "The full name of this chat participant, which is shown as the label for responses coming from this participant. If not provided, {0} is used.", '`name`'),
101
type: 'string'
102
},
103
description: {
104
description: localize('chatParticipantDescription', "A description of this chat participant, shown in the UI."),
105
type: 'string'
106
},
107
isSticky: {
108
description: localize('chatCommandSticky', "Whether invoking the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message."),
109
type: 'boolean'
110
},
111
sampleRequest: {
112
description: localize('chatSampleRequest', "When the user clicks this participant in `/help`, this text will be submitted to the participant."),
113
type: 'string'
114
},
115
when: {
116
description: localize('chatParticipantWhen', "A condition which must be true to enable this participant."),
117
type: 'string'
118
},
119
disambiguation: {
120
description: localize('chatParticipantDisambiguation', "Metadata to help with automatically routing user questions to this chat participant."),
121
type: 'array',
122
items: {
123
additionalProperties: false,
124
type: 'object',
125
defaultSnippets: [{ body: { category: '', description: '', examples: [] } }],
126
required: ['category', 'description', 'examples'],
127
properties: {
128
category: {
129
markdownDescription: localize('chatParticipantDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."),
130
type: 'string'
131
},
132
description: {
133
description: localize('chatParticipantDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat participant."),
134
type: 'string'
135
},
136
examples: {
137
description: localize('chatParticipantDisambiguationExamples', "A list of representative example questions that are suitable for this chat participant."),
138
type: 'array'
139
},
140
}
141
}
142
},
143
commands: {
144
markdownDescription: localize('chatCommandsDescription', "Commands available for this chat participant, which the user can invoke with a `/`."),
145
type: 'array',
146
items: {
147
additionalProperties: false,
148
type: 'object',
149
defaultSnippets: [{ body: { name: '', description: '' } }],
150
required: ['name'],
151
properties: {
152
name: {
153
description: localize('chatCommand', "A short name by which this command is referred to in the UI, e.g. `fix` or * `explain` for commands that fix an issue or explain code. The name should be unique among the commands provided by this participant."),
154
type: 'string'
155
},
156
description: {
157
description: localize('chatCommandDescription', "A description of this command."),
158
type: 'string'
159
},
160
when: {
161
description: localize('chatCommandWhen', "A condition which must be true to enable this command."),
162
type: 'string'
163
},
164
sampleRequest: {
165
description: localize('chatCommandSampleRequest', "When the user clicks this command in `/help`, this text will be submitted to the participant."),
166
type: 'string'
167
},
168
isSticky: {
169
description: localize('chatCommandSticky', "Whether invoking the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message."),
170
type: 'boolean'
171
},
172
disambiguation: {
173
description: localize('chatCommandDisambiguation', "Metadata to help with automatically routing user questions to this chat command."),
174
type: 'array',
175
items: {
176
additionalProperties: false,
177
type: 'object',
178
defaultSnippets: [{ body: { category: '', description: '', examples: [] } }],
179
required: ['category', 'description', 'examples'],
180
properties: {
181
category: {
182
markdownDescription: localize('chatCommandDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."),
183
type: 'string'
184
},
185
description: {
186
description: localize('chatCommandDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat command."),
187
type: 'string'
188
},
189
examples: {
190
description: localize('chatCommandDisambiguationExamples', "A list of representative example questions that are suitable for this chat command."),
191
type: 'array'
192
},
193
}
194
}
195
}
196
}
197
}
198
},
199
}
200
}
201
},
202
activationEventsGenerator: (contributions: IRawChatParticipantContribution[], result: { push(item: string): void }) => {
203
for (const contrib of contributions) {
204
result.push(`onChatParticipant:${contrib.id}`);
205
}
206
},
207
});
208
209
export class ChatExtensionPointHandler implements IWorkbenchContribution {
210
211
static readonly ID = 'workbench.contrib.chatExtensionPointHandler';
212
213
private _participantRegistrationDisposables = new DisposableMap<string>();
214
215
constructor(
216
@IChatAgentService private readonly _chatAgentService: IChatAgentService,
217
) {
218
this.handleAndRegisterChatExtensions();
219
}
220
221
private handleAndRegisterChatExtensions(): void {
222
chatParticipantExtensionPoint.setHandler((extensions, delta) => {
223
for (const extension of delta.added) {
224
for (const providerDescriptor of extension.value) {
225
if (!providerDescriptor.name?.match(/^[\w-]+$/)) {
226
extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with invalid name: ${providerDescriptor.name}. Name must match /^[\\w-]+$/.`);
227
continue;
228
}
229
230
if (providerDescriptor.fullName && strings.AmbiguousCharacters.getInstance(new Set()).containsAmbiguousCharacter(providerDescriptor.fullName)) {
231
extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains ambiguous characters: ${providerDescriptor.fullName}.`);
232
continue;
233
}
234
235
// Spaces are allowed but considered "invisible"
236
if (providerDescriptor.fullName && strings.InvisibleCharacters.containsInvisibleCharacter(providerDescriptor.fullName.replace(/ /g, ''))) {
237
extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains invisible characters: ${providerDescriptor.fullName}.`);
238
continue;
239
}
240
241
if ((providerDescriptor.isDefault || providerDescriptor.modes) && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) {
242
extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`);
243
continue;
244
}
245
246
if (providerDescriptor.locations && !isProposedApiEnabled(extension.description, 'chatParticipantAdditions')) {
247
extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: chatParticipantAdditions.`);
248
continue;
249
}
250
251
if (!providerDescriptor.id || !providerDescriptor.name) {
252
extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant without both id and name.`);
253
continue;
254
}
255
256
const participantsDisambiguation: {
257
category: string;
258
description: string;
259
examples: string[];
260
}[] = [];
261
262
if (providerDescriptor.disambiguation?.length) {
263
participantsDisambiguation.push(...providerDescriptor.disambiguation.map((d) => ({
264
...d, category: d.category ?? d.categoryName
265
})));
266
}
267
268
try {
269
const store = new DisposableStore();
270
store.add(this._chatAgentService.registerAgent(
271
providerDescriptor.id,
272
{
273
extensionId: extension.description.identifier,
274
extensionVersion: extension.description.version,
275
publisherDisplayName: extension.description.publisherDisplayName ?? extension.description.publisher, // May not be present in OSS
276
extensionPublisherId: extension.description.publisher,
277
extensionDisplayName: extension.description.displayName ?? extension.description.name,
278
id: providerDescriptor.id,
279
description: providerDescriptor.description,
280
when: providerDescriptor.when,
281
metadata: {
282
isSticky: providerDescriptor.isSticky,
283
sampleRequest: providerDescriptor.sampleRequest,
284
},
285
name: providerDescriptor.name,
286
fullName: providerDescriptor.fullName,
287
isDefault: providerDescriptor.isDefault,
288
locations: isNonEmptyArray(providerDescriptor.locations) ?
289
providerDescriptor.locations.map(ChatAgentLocation.fromRaw) :
290
[ChatAgentLocation.Panel],
291
modes: providerDescriptor.isDefault ? (providerDescriptor.modes ?? [ChatModeKind.Ask]) : [ChatModeKind.Agent, ChatModeKind.Ask, ChatModeKind.Edit],
292
slashCommands: providerDescriptor.commands ?? [],
293
disambiguation: coalesce(participantsDisambiguation.flat()),
294
} satisfies IChatAgentData));
295
296
this._participantRegistrationDisposables.set(
297
getParticipantKey(extension.description.identifier, providerDescriptor.id),
298
store
299
);
300
} catch (e) {
301
extension.collector.error(`Failed to register participant ${providerDescriptor.id}: ${toErrorMessage(e, true)}`);
302
}
303
}
304
}
305
306
for (const extension of delta.removed) {
307
for (const providerDescriptor of extension.value) {
308
this._participantRegistrationDisposables.deleteAndDispose(getParticipantKey(extension.description.identifier, providerDescriptor.id));
309
}
310
}
311
});
312
}
313
}
314
315
function getParticipantKey(extensionId: ExtensionIdentifier, participantName: string): string {
316
return `${extensionId.value}_${participantName}`;
317
}
318
319
export class ChatCompatibilityNotifier extends Disposable implements IWorkbenchContribution {
320
static readonly ID = 'workbench.contrib.chatCompatNotifier';
321
322
private registeredWelcomeView = false;
323
324
constructor(
325
@IExtensionsWorkbenchService extensionsWorkbenchService: IExtensionsWorkbenchService,
326
@IContextKeyService contextKeyService: IContextKeyService,
327
@IProductService private readonly productService: IProductService,
328
) {
329
super();
330
331
// It may be better to have some generic UI for this, for any extension that is incompatible,
332
// but this is only enabled for Copilot Chat now and it needs to be obvious.
333
const isInvalid = ChatContextKeys.extensionInvalid.bindTo(contextKeyService);
334
this._register(Event.runAndSubscribe(
335
extensionsWorkbenchService.onDidChangeExtensionsNotification,
336
() => {
337
const notification = extensionsWorkbenchService.getExtensionsNotification();
338
const chatExtension = notification?.extensions.find(ext => ExtensionIdentifier.equals(ext.identifier.id, this.productService.defaultChatAgent?.chatExtensionId));
339
if (chatExtension) {
340
isInvalid.set(true);
341
this.registerWelcomeView(chatExtension);
342
} else {
343
isInvalid.set(false);
344
}
345
}
346
));
347
}
348
349
private registerWelcomeView(chatExtension: IExtension) {
350
if (this.registeredWelcomeView) {
351
return;
352
}
353
354
this.registeredWelcomeView = true;
355
const showExtensionLabel = localize('showExtension', "Show Extension");
356
const mainMessage = localize('chatFailErrorMessage', "Chat failed to load because the installed version of the Copilot Chat extension is not compatible with this version of {0}. Please ensure that the Copilot Chat extension is up to date.", this.productService.nameLong);
357
const commandButton = `[${showExtensionLabel}](command:${showExtensionsWithIdsCommandId}?${encodeURIComponent(JSON.stringify([[this.productService.defaultChatAgent?.chatExtensionId]]))})`;
358
const versionMessage = `Copilot Chat version: ${chatExtension.version}`;
359
const viewsRegistry = Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry);
360
this._register(viewsRegistry.registerViewWelcomeContent(ChatViewId, {
361
content: [mainMessage, commandButton, versionMessage].join('\n\n'),
362
when: ChatContextKeys.extensionInvalid,
363
}));
364
}
365
}
366
367
class ChatParticipantDataRenderer extends Disposable implements IExtensionFeatureTableRenderer {
368
readonly type = 'table';
369
370
shouldRender(manifest: IExtensionManifest): boolean {
371
return !!manifest.contributes?.chatParticipants;
372
}
373
374
render(manifest: IExtensionManifest): IRenderedData<ITableData> {
375
const nonDefaultContributions = manifest.contributes?.chatParticipants?.filter(c => !c.isDefault) ?? [];
376
if (!nonDefaultContributions.length) {
377
return { data: { headers: [], rows: [] }, dispose: () => { } };
378
}
379
380
const headers = [
381
localize('participantName', "Name"),
382
localize('participantFullName', "Full Name"),
383
localize('participantDescription', "Description"),
384
localize('participantCommands', "Commands"),
385
];
386
387
const rows: IRowData[][] = nonDefaultContributions.map(d => {
388
return [
389
'@' + d.name,
390
d.fullName,
391
d.description ?? '-',
392
d.commands?.length ? new MarkdownString(d.commands.map(c => `- /` + c.name).join('\n')) : '-'
393
];
394
});
395
396
return {
397
data: {
398
headers,
399
rows
400
},
401
dispose: () => { }
402
};
403
}
404
}
405
406
Registry.as<IExtensionFeaturesRegistry>(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({
407
id: 'chatParticipants',
408
label: localize('chatParticipants', "Chat Participants"),
409
access: {
410
canToggle: false
411
},
412
renderer: new SyncDescriptor(ChatParticipantDataRenderer),
413
});
414
415