Path: blob/main/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts
5270 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { coalesce, isNonEmptyArray } from '../../../../base/common/arrays.js';6import { Codicon } from '../../../../base/common/codicons.js';7import { toErrorMessage } from '../../../../base/common/errorMessage.js';8import { Event } from '../../../../base/common/event.js';9import { createCommandUri, MarkdownString } from '../../../../base/common/htmlContent.js';10import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';11import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js';12import * as strings from '../../../../base/common/strings.js';13import { localize, localize2 } from '../../../../nls.js';14import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';15import { ExtensionIdentifier, IExtensionManifest } from '../../../../platform/extensions/common/extensions.js';16import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';17import { IProductService } from '../../../../platform/product/common/productService.js';18import { Registry } from '../../../../platform/registry/common/platform.js';19import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';20import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';21import { IWorkbenchContribution } from '../../../common/contributions.js';22import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js';23import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../services/extensionManagement/common/extensionFeatures.js';24import { isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';25import * as extensionsRegistry from '../../../services/extensions/common/extensionsRegistry.js';26import { showExtensionsWithIdsCommandId } from '../../extensions/browser/extensionsActions.js';27import { IExtension, IExtensionsWorkbenchService } from '../../extensions/common/extensions.js';28import { IChatAgentData, IChatAgentService } from '../common/participants/chatAgents.js';29import { ChatContextKeys } from '../common/actions/chatContextKeys.js';30import { IRawChatParticipantContribution } from '../common/participants/chatParticipantContribTypes.js';31import { ChatAgentLocation, ChatModeKind } from '../common/constants.js';32import { ChatViewId, ChatViewContainerId } from './chat.js';33import { ChatViewPane } from './widgetHosts/viewPane/chatViewPane.js';3435// --- Chat Container & View Registration3637const chatViewIcon = registerIcon('chat-view-icon', Codicon.chatSparkle, localize('chatViewIcon', 'View icon of the chat view.'));3839const chatViewContainer: ViewContainer = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).registerViewContainer({40id: ChatViewContainerId,41title: localize2('chat.viewContainer.label', "Chat"),42icon: chatViewIcon,43ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [ChatViewContainerId, { mergeViewWithContainerWhenSingleView: true }]),44storageId: ChatViewContainerId,45hideIfEmpty: true,46order: 1,47}, ViewContainerLocation.AuxiliaryBar, { isDefault: true, doNotRegisterOpenCommand: true });4849const chatViewDescriptor: IViewDescriptor = {50id: ChatViewId,51containerIcon: chatViewContainer.icon,52containerTitle: chatViewContainer.title.value,53singleViewPaneContainerTitle: chatViewContainer.title.value,54name: localize2('chat.viewContainer.label', "Chat"),55canToggleVisibility: false,56canMoveView: true,57openCommandActionDescriptor: {58id: ChatViewContainerId,59title: chatViewContainer.title,60mnemonicTitle: localize({ key: 'miToggleChat', comment: ['&& denotes a mnemonic'] }, "&&Chat"),61keybindings: {62primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI,63mac: {64primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI65}66},67order: 168},69ctorDescriptor: new SyncDescriptor(ChatViewPane),70when: ContextKeyExpr.or(71ContextKeyExpr.or(72ChatContextKeys.Setup.hidden,73ChatContextKeys.Setup.disabled74)?.negate(),75ChatContextKeys.panelParticipantRegistered,76ChatContextKeys.extensionInvalid77)78};79Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([chatViewDescriptor], chatViewContainer);8081const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatParticipantContribution[]>({82extensionPoint: 'chatParticipants',83jsonSchema: {84description: localize('vscode.extension.contributes.chatParticipant', 'Contributes a chat participant'),85type: 'array',86items: {87additionalProperties: false,88type: 'object',89defaultSnippets: [{ body: { name: '', description: '' } }],90required: ['name', 'id'],91properties: {92id: {93description: localize('chatParticipantId', "A unique id for this chat participant."),94type: 'string'95},96name: {97description: 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."),98type: 'string',99pattern: '^[\\w-]+$'100},101fullName: {102markdownDescription: 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`'),103type: 'string'104},105description: {106description: localize('chatParticipantDescription', "A description of this chat participant, shown in the UI."),107type: 'string'108},109isSticky: {110description: 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."),111type: 'boolean'112},113sampleRequest: {114description: localize('chatSampleRequest', "When the user clicks this participant in `/help`, this text will be submitted to the participant."),115type: 'string'116},117when: {118description: localize('chatParticipantWhen', "A condition which must be true to enable this participant."),119type: 'string'120},121disambiguation: {122description: localize('chatParticipantDisambiguation', "Metadata to help with automatically routing user questions to this chat participant."),123type: 'array',124items: {125additionalProperties: false,126type: 'object',127defaultSnippets: [{ body: { category: '', description: '', examples: [] } }],128required: ['category', 'description', 'examples'],129properties: {130category: {131markdownDescription: localize('chatParticipantDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."),132type: 'string'133},134description: {135description: localize('chatParticipantDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat participant."),136type: 'string'137},138examples: {139description: localize('chatParticipantDisambiguationExamples', "A list of representative example questions that are suitable for this chat participant."),140type: 'array'141},142}143}144},145commands: {146markdownDescription: localize('chatCommandsDescription', "Commands available for this chat participant, which the user can invoke with a `/`."),147type: 'array',148items: {149additionalProperties: false,150type: 'object',151defaultSnippets: [{ body: { name: '', description: '' } }],152required: ['name'],153properties: {154name: {155description: 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."),156type: 'string'157},158description: {159description: localize('chatCommandDescription', "A description of this command."),160type: 'string'161},162when: {163description: localize('chatCommandWhen', "A condition which must be true to enable this command."),164type: 'string'165},166sampleRequest: {167description: localize('chatCommandSampleRequest', "When the user clicks this command in `/help`, this text will be submitted to the participant."),168type: 'string'169},170isSticky: {171description: 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."),172type: 'boolean'173},174disambiguation: {175description: localize('chatCommandDisambiguation', "Metadata to help with automatically routing user questions to this chat command."),176type: 'array',177items: {178additionalProperties: false,179type: 'object',180defaultSnippets: [{ body: { category: '', description: '', examples: [] } }],181required: ['category', 'description', 'examples'],182properties: {183category: {184markdownDescription: localize('chatCommandDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."),185type: 'string'186},187description: {188description: localize('chatCommandDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat command."),189type: 'string'190},191examples: {192description: localize('chatCommandDisambiguationExamples', "A list of representative example questions that are suitable for this chat command."),193type: 'array'194},195}196}197}198}199}200},201}202}203},204activationEventsGenerator: function* (contributions: readonly IRawChatParticipantContribution[]) {205for (const contrib of contributions) {206yield `onChatParticipant:${contrib.id}`;207}208},209});210211export class ChatExtensionPointHandler implements IWorkbenchContribution {212213static readonly ID = 'workbench.contrib.chatExtensionPointHandler';214215private _participantRegistrationDisposables = new DisposableMap<string>();216217constructor(218@IChatAgentService private readonly _chatAgentService: IChatAgentService,219) {220this.handleAndRegisterChatExtensions();221}222223private handleAndRegisterChatExtensions(): void {224chatParticipantExtensionPoint.setHandler((extensions, delta) => {225for (const extension of delta.added) {226for (const providerDescriptor of extension.value) {227if (!providerDescriptor.name?.match(/^[\w-]+$/)) {228extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with invalid name: ${providerDescriptor.name}. Name must match /^[\\w-]+$/.`);229continue;230}231232if (providerDescriptor.fullName && strings.AmbiguousCharacters.getInstance(new Set()).containsAmbiguousCharacter(providerDescriptor.fullName)) {233extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains ambiguous characters: ${providerDescriptor.fullName}.`);234continue;235}236237// Spaces are allowed but considered "invisible"238if (providerDescriptor.fullName && strings.InvisibleCharacters.containsInvisibleCharacter(providerDescriptor.fullName.replace(/ /g, ''))) {239extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains invisible characters: ${providerDescriptor.fullName}.`);240continue;241}242243if ((providerDescriptor.isDefault || providerDescriptor.modes) && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) {244extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`);245continue;246}247248if (providerDescriptor.locations && !isProposedApiEnabled(extension.description, 'chatParticipantAdditions')) {249extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: chatParticipantAdditions.`);250continue;251}252253if (!providerDescriptor.id || !providerDescriptor.name) {254extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant without both id and name.`);255continue;256}257258const participantsDisambiguation: {259category: string;260description: string;261examples: string[];262}[] = [];263264if (providerDescriptor.disambiguation?.length) {265participantsDisambiguation.push(...providerDescriptor.disambiguation.map((d) => ({266...d, category: d.category ?? d.categoryName267})));268}269270try {271const store = new DisposableStore();272store.add(this._chatAgentService.registerAgent(273providerDescriptor.id,274{275extensionId: extension.description.identifier,276extensionVersion: extension.description.version,277publisherDisplayName: extension.description.publisherDisplayName ?? extension.description.publisher, // May not be present in OSS278extensionPublisherId: extension.description.publisher,279extensionDisplayName: extension.description.displayName ?? extension.description.name,280id: providerDescriptor.id,281description: providerDescriptor.description,282when: providerDescriptor.when,283metadata: {284isSticky: providerDescriptor.isSticky,285sampleRequest: providerDescriptor.sampleRequest,286},287name: providerDescriptor.name,288fullName: providerDescriptor.fullName,289isDefault: providerDescriptor.isDefault,290locations: isNonEmptyArray(providerDescriptor.locations) ?291providerDescriptor.locations.map(ChatAgentLocation.fromRaw) :292[ChatAgentLocation.Chat],293modes: providerDescriptor.isDefault ? (providerDescriptor.modes ?? [ChatModeKind.Ask]) : [ChatModeKind.Agent, ChatModeKind.Ask, ChatModeKind.Edit],294slashCommands: providerDescriptor.commands ?? [],295disambiguation: coalesce(participantsDisambiguation.flat()),296} satisfies IChatAgentData));297298this._participantRegistrationDisposables.set(299getParticipantKey(extension.description.identifier, providerDescriptor.id),300store301);302} catch (e) {303extension.collector.error(`Failed to register participant ${providerDescriptor.id}: ${toErrorMessage(e, true)}`);304}305}306}307308for (const extension of delta.removed) {309for (const providerDescriptor of extension.value) {310this._participantRegistrationDisposables.deleteAndDispose(getParticipantKey(extension.description.identifier, providerDescriptor.id));311}312}313});314}315}316317function getParticipantKey(extensionId: ExtensionIdentifier, participantName: string): string {318return `${extensionId.value}_${participantName}`;319}320321export class ChatCompatibilityNotifier extends Disposable implements IWorkbenchContribution {322static readonly ID = 'workbench.contrib.chatCompatNotifier';323324private registeredWelcomeView = false;325326constructor(327@IExtensionsWorkbenchService extensionsWorkbenchService: IExtensionsWorkbenchService,328@IContextKeyService contextKeyService: IContextKeyService,329@IProductService private readonly productService: IProductService,330) {331super();332333// It may be better to have some generic UI for this, for any extension that is incompatible,334// but this is only enabled for Chat now and it needs to be obvious.335const isInvalid = ChatContextKeys.extensionInvalid.bindTo(contextKeyService);336this._register(Event.runAndSubscribe(337extensionsWorkbenchService.onDidChangeExtensionsNotification,338() => {339const notification = extensionsWorkbenchService.getExtensionsNotification();340const chatExtension = notification?.extensions.find(ext => ExtensionIdentifier.equals(ext.identifier.id, this.productService.defaultChatAgent?.chatExtensionId));341if (chatExtension) {342isInvalid.set(true);343this.registerWelcomeView(chatExtension);344} else {345isInvalid.set(false);346}347}348));349}350351private registerWelcomeView(chatExtension: IExtension) {352if (this.registeredWelcomeView) {353return;354}355356this.registeredWelcomeView = true;357const showExtensionLabel = localize('showExtension', "Show Extension");358const 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);359const commandButton = `[${showExtensionLabel}](${createCommandUri(showExtensionsWithIdsCommandId, [this.productService.defaultChatAgent?.chatExtensionId])})`;360const versionMessage = `Copilot Chat version: ${chatExtension.version}`;361const viewsRegistry = Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry);362this._register(viewsRegistry.registerViewWelcomeContent(ChatViewId, {363content: [mainMessage, commandButton, versionMessage].join('\n\n'),364when: ChatContextKeys.extensionInvalid,365}));366}367}368369class ChatParticipantDataRenderer extends Disposable implements IExtensionFeatureTableRenderer {370readonly type = 'table';371372shouldRender(manifest: IExtensionManifest): boolean {373return !!manifest.contributes?.chatParticipants;374}375376render(manifest: IExtensionManifest): IRenderedData<ITableData> {377const nonDefaultContributions = manifest.contributes?.chatParticipants?.filter(c => !c.isDefault) ?? [];378if (!nonDefaultContributions.length) {379return { data: { headers: [], rows: [] }, dispose: () => { } };380}381382const headers = [383localize('participantName', "Name"),384localize('participantFullName', "Full Name"),385localize('participantDescription', "Description"),386localize('participantCommands', "Commands"),387];388389const rows: IRowData[][] = nonDefaultContributions.map(d => {390return [391'@' + d.name,392d.fullName,393d.description ?? '-',394d.commands?.length ? new MarkdownString(d.commands.map(c => `- /` + c.name).join('\n')) : '-'395];396});397398return {399data: {400headers,401rows402},403dispose: () => { }404};405}406}407408Registry.as<IExtensionFeaturesRegistry>(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({409id: 'chatParticipants',410label: localize('chatParticipants', "Chat Participants"),411access: {412canToggle: false413},414renderer: new SyncDescriptor(ChatParticipantDataRenderer),415});416417418