Path: blob/main/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts
3296 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 { 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 { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';20import { IWorkbenchContribution } from '../../../common/contributions.js';21import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js';22import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../services/extensionManagement/common/extensionFeatures.js';23import { isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';24import * as extensionsRegistry from '../../../services/extensions/common/extensionsRegistry.js';25import { showExtensionsWithIdsCommandId } from '../../extensions/browser/extensionsActions.js';26import { IExtension, IExtensionsWorkbenchService } from '../../extensions/common/extensions.js';27import { IChatAgentData, IChatAgentService } from '../common/chatAgents.js';28import { ChatContextKeys } from '../common/chatContextKeys.js';29import { IRawChatParticipantContribution } from '../common/chatParticipantContribTypes.js';30import { ChatAgentLocation, ChatModeKind } from '../common/constants.js';31import { ChatViewId } from './chat.js';32import { CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './chatViewPane.js';3334// --- Chat Container & View Registration3536const chatViewContainer: ViewContainer = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).registerViewContainer({37id: CHAT_SIDEBAR_PANEL_ID,38title: localize2('chat.viewContainer.label', "Chat"),39icon: Codicon.chatSparkle,40ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [CHAT_SIDEBAR_PANEL_ID, { mergeViewWithContainerWhenSingleView: true }]),41storageId: CHAT_SIDEBAR_PANEL_ID,42hideIfEmpty: true,43order: 1,44}, ViewContainerLocation.AuxiliaryBar, { isDefault: true, doNotRegisterOpenCommand: true });4546const chatViewDescriptor: IViewDescriptor[] = [{47id: ChatViewId,48containerIcon: chatViewContainer.icon,49containerTitle: chatViewContainer.title.value,50singleViewPaneContainerTitle: chatViewContainer.title.value,51name: localize2('chat.viewContainer.label', "Chat"),52canToggleVisibility: false,53canMoveView: true,54openCommandActionDescriptor: {55id: CHAT_SIDEBAR_PANEL_ID,56title: chatViewContainer.title,57mnemonicTitle: localize({ key: 'miToggleChat', comment: ['&& denotes a mnemonic'] }, "&&Chat"),58keybindings: {59primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI,60mac: {61primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI62}63},64order: 165},66ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ location: ChatAgentLocation.Panel }]),67when: ContextKeyExpr.or(68ContextKeyExpr.or(69ChatContextKeys.Setup.hidden,70ChatContextKeys.Setup.disabled71)?.negate(),72ChatContextKeys.panelParticipantRegistered,73ChatContextKeys.extensionInvalid74)75}];76Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews(chatViewDescriptor, chatViewContainer);7778const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatParticipantContribution[]>({79extensionPoint: 'chatParticipants',80jsonSchema: {81description: localize('vscode.extension.contributes.chatParticipant', 'Contributes a chat participant'),82type: 'array',83items: {84additionalProperties: false,85type: 'object',86defaultSnippets: [{ body: { name: '', description: '' } }],87required: ['name', 'id'],88properties: {89id: {90description: localize('chatParticipantId', "A unique id for this chat participant."),91type: 'string'92},93name: {94description: 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."),95type: 'string',96pattern: '^[\\w-]+$'97},98fullName: {99markdownDescription: 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`'),100type: 'string'101},102description: {103description: localize('chatParticipantDescription', "A description of this chat participant, shown in the UI."),104type: 'string'105},106isSticky: {107description: 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."),108type: 'boolean'109},110sampleRequest: {111description: localize('chatSampleRequest', "When the user clicks this participant in `/help`, this text will be submitted to the participant."),112type: 'string'113},114when: {115description: localize('chatParticipantWhen', "A condition which must be true to enable this participant."),116type: 'string'117},118disambiguation: {119description: localize('chatParticipantDisambiguation', "Metadata to help with automatically routing user questions to this chat participant."),120type: 'array',121items: {122additionalProperties: false,123type: 'object',124defaultSnippets: [{ body: { category: '', description: '', examples: [] } }],125required: ['category', 'description', 'examples'],126properties: {127category: {128markdownDescription: localize('chatParticipantDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."),129type: 'string'130},131description: {132description: localize('chatParticipantDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat participant."),133type: 'string'134},135examples: {136description: localize('chatParticipantDisambiguationExamples', "A list of representative example questions that are suitable for this chat participant."),137type: 'array'138},139}140}141},142commands: {143markdownDescription: localize('chatCommandsDescription', "Commands available for this chat participant, which the user can invoke with a `/`."),144type: 'array',145items: {146additionalProperties: false,147type: 'object',148defaultSnippets: [{ body: { name: '', description: '' } }],149required: ['name'],150properties: {151name: {152description: 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."),153type: 'string'154},155description: {156description: localize('chatCommandDescription', "A description of this command."),157type: 'string'158},159when: {160description: localize('chatCommandWhen', "A condition which must be true to enable this command."),161type: 'string'162},163sampleRequest: {164description: localize('chatCommandSampleRequest', "When the user clicks this command in `/help`, this text will be submitted to the participant."),165type: 'string'166},167isSticky: {168description: 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."),169type: 'boolean'170},171disambiguation: {172description: localize('chatCommandDisambiguation', "Metadata to help with automatically routing user questions to this chat command."),173type: 'array',174items: {175additionalProperties: false,176type: 'object',177defaultSnippets: [{ body: { category: '', description: '', examples: [] } }],178required: ['category', 'description', 'examples'],179properties: {180category: {181markdownDescription: localize('chatCommandDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."),182type: 'string'183},184description: {185description: localize('chatCommandDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat command."),186type: 'string'187},188examples: {189description: localize('chatCommandDisambiguationExamples', "A list of representative example questions that are suitable for this chat command."),190type: 'array'191},192}193}194}195}196}197},198}199}200},201activationEventsGenerator: (contributions: IRawChatParticipantContribution[], result: { push(item: string): void }) => {202for (const contrib of contributions) {203result.push(`onChatParticipant:${contrib.id}`);204}205},206});207208export class ChatExtensionPointHandler implements IWorkbenchContribution {209210static readonly ID = 'workbench.contrib.chatExtensionPointHandler';211212private _participantRegistrationDisposables = new DisposableMap<string>();213214constructor(215@IChatAgentService private readonly _chatAgentService: IChatAgentService,216) {217this.handleAndRegisterChatExtensions();218}219220private handleAndRegisterChatExtensions(): void {221chatParticipantExtensionPoint.setHandler((extensions, delta) => {222for (const extension of delta.added) {223for (const providerDescriptor of extension.value) {224if (!providerDescriptor.name?.match(/^[\w-]+$/)) {225extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with invalid name: ${providerDescriptor.name}. Name must match /^[\\w-]+$/.`);226continue;227}228229if (providerDescriptor.fullName && strings.AmbiguousCharacters.getInstance(new Set()).containsAmbiguousCharacter(providerDescriptor.fullName)) {230extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains ambiguous characters: ${providerDescriptor.fullName}.`);231continue;232}233234// Spaces are allowed but considered "invisible"235if (providerDescriptor.fullName && strings.InvisibleCharacters.containsInvisibleCharacter(providerDescriptor.fullName.replace(/ /g, ''))) {236extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains invisible characters: ${providerDescriptor.fullName}.`);237continue;238}239240if ((providerDescriptor.isDefault || providerDescriptor.modes) && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) {241extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`);242continue;243}244245if (providerDescriptor.locations && !isProposedApiEnabled(extension.description, 'chatParticipantAdditions')) {246extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: chatParticipantAdditions.`);247continue;248}249250if (!providerDescriptor.id || !providerDescriptor.name) {251extension.collector.error(`Extension '${extension.description.identifier.value}' CANNOT register participant without both id and name.`);252continue;253}254255const participantsDisambiguation: {256category: string;257description: string;258examples: string[];259}[] = [];260261if (providerDescriptor.disambiguation?.length) {262participantsDisambiguation.push(...providerDescriptor.disambiguation.map((d) => ({263...d, category: d.category ?? d.categoryName264})));265}266267try {268const store = new DisposableStore();269store.add(this._chatAgentService.registerAgent(270providerDescriptor.id,271{272extensionId: extension.description.identifier,273extensionVersion: extension.description.version,274publisherDisplayName: extension.description.publisherDisplayName ?? extension.description.publisher, // May not be present in OSS275extensionPublisherId: extension.description.publisher,276extensionDisplayName: extension.description.displayName ?? extension.description.name,277id: providerDescriptor.id,278description: providerDescriptor.description,279when: providerDescriptor.when,280metadata: {281isSticky: providerDescriptor.isSticky,282sampleRequest: providerDescriptor.sampleRequest,283},284name: providerDescriptor.name,285fullName: providerDescriptor.fullName,286isDefault: providerDescriptor.isDefault,287locations: isNonEmptyArray(providerDescriptor.locations) ?288providerDescriptor.locations.map(ChatAgentLocation.fromRaw) :289[ChatAgentLocation.Panel],290modes: providerDescriptor.isDefault ? (providerDescriptor.modes ?? [ChatModeKind.Ask]) : [ChatModeKind.Agent, ChatModeKind.Ask, ChatModeKind.Edit],291slashCommands: providerDescriptor.commands ?? [],292disambiguation: coalesce(participantsDisambiguation.flat()),293} satisfies IChatAgentData));294295this._participantRegistrationDisposables.set(296getParticipantKey(extension.description.identifier, providerDescriptor.id),297store298);299} catch (e) {300extension.collector.error(`Failed to register participant ${providerDescriptor.id}: ${toErrorMessage(e, true)}`);301}302}303}304305for (const extension of delta.removed) {306for (const providerDescriptor of extension.value) {307this._participantRegistrationDisposables.deleteAndDispose(getParticipantKey(extension.description.identifier, providerDescriptor.id));308}309}310});311}312}313314function getParticipantKey(extensionId: ExtensionIdentifier, participantName: string): string {315return `${extensionId.value}_${participantName}`;316}317318export class ChatCompatibilityNotifier extends Disposable implements IWorkbenchContribution {319static readonly ID = 'workbench.contrib.chatCompatNotifier';320321private registeredWelcomeView = false;322323constructor(324@IExtensionsWorkbenchService extensionsWorkbenchService: IExtensionsWorkbenchService,325@IContextKeyService contextKeyService: IContextKeyService,326@IProductService private readonly productService: IProductService,327) {328super();329330// It may be better to have some generic UI for this, for any extension that is incompatible,331// but this is only enabled for Copilot Chat now and it needs to be obvious.332const isInvalid = ChatContextKeys.extensionInvalid.bindTo(contextKeyService);333this._register(Event.runAndSubscribe(334extensionsWorkbenchService.onDidChangeExtensionsNotification,335() => {336const notification = extensionsWorkbenchService.getExtensionsNotification();337const chatExtension = notification?.extensions.find(ext => ExtensionIdentifier.equals(ext.identifier.id, this.productService.defaultChatAgent?.chatExtensionId));338if (chatExtension) {339isInvalid.set(true);340this.registerWelcomeView(chatExtension);341} else {342isInvalid.set(false);343}344}345));346}347348private registerWelcomeView(chatExtension: IExtension) {349if (this.registeredWelcomeView) {350return;351}352353this.registeredWelcomeView = true;354const showExtensionLabel = localize('showExtension', "Show Extension");355const 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);356const commandButton = `[${showExtensionLabel}](command:${showExtensionsWithIdsCommandId}?${encodeURIComponent(JSON.stringify([[this.productService.defaultChatAgent?.chatExtensionId]]))})`;357const versionMessage = `Copilot Chat version: ${chatExtension.version}`;358const viewsRegistry = Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry);359this._register(viewsRegistry.registerViewWelcomeContent(ChatViewId, {360content: [mainMessage, commandButton, versionMessage].join('\n\n'),361when: ChatContextKeys.extensionInvalid,362}));363}364}365366class ChatParticipantDataRenderer extends Disposable implements IExtensionFeatureTableRenderer {367readonly type = 'table';368369shouldRender(manifest: IExtensionManifest): boolean {370return !!manifest.contributes?.chatParticipants;371}372373render(manifest: IExtensionManifest): IRenderedData<ITableData> {374const nonDefaultContributions = manifest.contributes?.chatParticipants?.filter(c => !c.isDefault) ?? [];375if (!nonDefaultContributions.length) {376return { data: { headers: [], rows: [] }, dispose: () => { } };377}378379const headers = [380localize('participantName', "Name"),381localize('participantFullName', "Full Name"),382localize('participantDescription', "Description"),383localize('participantCommands', "Commands"),384];385386const rows: IRowData[][] = nonDefaultContributions.map(d => {387return [388'@' + d.name,389d.fullName,390d.description ?? '-',391d.commands?.length ? new MarkdownString(d.commands.map(c => `- /` + c.name).join('\n')) : '-'392];393});394395return {396data: {397headers,398rows399},400dispose: () => { }401};402}403}404405Registry.as<IExtensionFeaturesRegistry>(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({406id: 'chatParticipants',407label: localize('chatParticipants', "Chat Participants"),408access: {409canToggle: false410},411renderer: new SyncDescriptor(ChatParticipantDataRenderer),412});413414415