From cebb5e5a055eeb35925c542db6cf39fbfb5e95d0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 16 Apr 2025 16:21:04 +0200 Subject: [PATCH] introduce search extensions tool (#246721) * introduce search extensions tool * update tool reference name --- .../api/common/extHostLanguageModelTools.ts | 2 + .../chatExtensionsContentPart.ts | 6 + .../chatMarkdownContentPart.ts | 6 + .../media/chatExtensionsContent.css | 13 ++ .../contrib/chat/browser/media/chat.css | 12 +- .../browser/extensions.contribution.ts | 21 ++- .../extensions/common/searchExtensionsTool.ts | 132 ++++++++++++++++++ 7 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 30d23b06da0..fcbd6c80691 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -19,6 +19,7 @@ import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostLanguageModelToolsShape, IMainContext, IToolDataDto, MainContext, MainThreadLanguageModelToolsShape } from './extHost.protocol.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; import * as typeConvert from './extHostTypeConverters.js'; +import { SearchExtensionsToolId } from '../../contrib/extensions/common/searchExtensionsTool.js'; export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape { /** A map of tools that were registered in this EH */ @@ -97,6 +98,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape case InternalEditToolId: case ExtensionEditToolId: case InternalFetchWebPageToolId: + case SearchExtensionsToolId: return isProposedApiEnabled(extension, 'chatParticipantPrivate'); default: return true; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatExtensionsContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatExtensionsContentPart.ts index 6ac6be2c970..c042eecfc03 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatExtensionsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatExtensionsContentPart.ts @@ -15,6 +15,9 @@ import { IChatRendererContent } from '../../common/chatViewModel.js'; import { ChatTreeItem, ChatViewId, IChatCodeBlockInfo } from '../chat.js'; import { IChatContentPart } from './chatContentParts.js'; import { PagedModel } from '../../../../../base/common/paging.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; export class ChatExtensionsContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; @@ -38,10 +41,13 @@ export class ChatExtensionsContentPart extends Disposable implements IChatConten super(); this.domNode = dom.$('.chat-extensions-content-part'); + const loadingElement = dom.append(this.domNode, dom.$('.loading-extensions-element')); + dom.append(loadingElement, dom.$(ThemeIcon.asCSSSelector(ThemeIcon.modify(Codicon.loading, 'spin'))), dom.$('span.loading-message', undefined, localize('chat.extensions.loading', 'Loading extensions...'))); const extensionsList = dom.append(this.domNode, dom.$('.extensions-list')); const list = this._register(instantiationService.createInstance(ExtensionsList, extensionsList, ChatViewId, { alwaysConsumeMouseWheel: false }, { onFocus: Event.None, onBlur: Event.None, filters: {} })); getExtensions(extensionsContent.extensions, extensionsWorkbenchService).then(extensions => { + loadingElement.remove(); if (this._store.isDisposed) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts index 322e9c65712..68aa1a45d3e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts @@ -45,6 +45,7 @@ import { CodeBlockPart, ICodeBlockData, ICodeBlockRenderOptions, localFileLangua import '../media/chatCodeBlockPill.css'; import { IDisposableReference, ResourcePool } from './chatCollections.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { ChatExtensionsContentPart } from './chatExtensionsContentPart.js'; const $ = dom.$; @@ -105,6 +106,11 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP hideEmptyCodeblock.style.display = 'none'; return hideEmptyCodeblock; } + if (languageId === 'vscode-extensions') { + const chatExtensions = this._register(instantiationService.createInstance(ChatExtensionsContentPart, { kind: 'extensions', extensions: text.split(',') })); + this._register(chatExtensions.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + return chatExtensions.domNode; + } const globalIndex = globalCodeBlockIndexStart++; const thisPartIndex = thisPartCodeBlockIndexStart++; let textModel: Promise; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatExtensionsContent.css b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatExtensionsContent.css index 61a66ee4bc3..f9be266abd3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatExtensionsContent.css +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatExtensionsContent.css @@ -12,3 +12,16 @@ .chat-extensions-content-part .extension-list-item { border-bottom: 1px solid var(--vscode-chat-requestBorder); } + +.chat-extensions-content-part .loading-extensions-element { + line-height: 18px; + padding: 4px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + user-select: none; + border-bottom: 1px solid var(--vscode-chat-requestBorder); +} + +.chat-extensions-content-part .loading-extensions-element .loading-message { + padding-left: 4px; +} diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index 63f153f33f3..7cd7e69125d 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -313,13 +313,13 @@ font-weight: unset; } -.interactive-item-container .value .rendered-markdown { - /* Codicons next to text need to be aligned with the text */ - .codicon { - position: relative; - top: 2px; - } +/* Codicons next to text need to be aligned with the text */ +.interactive-item-container .value .rendered-markdown:not(:has(.chat-extensions-content-part)) .codicon { + position: relative; + top: 2px; +} +.interactive-item-container .value .rendered-markdown { .chat-codeblock-pill-widget .codicon { top: -1px; } diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 4c44d3bb91b..a884c1181ea 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -11,7 +11,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApiConfigKey, SortBy, FilterType, VerifyExtensionSignatureConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { EnablementState, IExtensionManagementServerService, IPublisherInfo, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; -import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from '../../../common/contributions.js'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { VIEWLET_ID, IExtensionsWorkbenchService, IExtensionsViewPaneContainer, TOGGLE_IGNORE_EXTENSION_ACTION_ID, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID, IWorkspaceRecommendedExtensionsView, AutoUpdateConfigurationKey, HasOutdatedExtensionsContext, SELECT_INSTALL_VSIX_EXTENSION_COMMAND_ID, LIST_WORKSPACE_UNSUPPORTED_EXTENSIONS_COMMAND_ID, ExtensionEditorTab, THEME_ACTIONS_GROUP, INSTALL_ACTIONS_GROUP, OUTDATED_EXTENSIONS_VIEW_ID, CONTEXT_HAS_GALLERY, extensionsSearchActionsMenu, UPDATE_ACTIONS_GROUP, IExtensionArg, ExtensionRuntimeActionType, EXTENSIONS_CATEGORY, AutoRestartConfigurationKey } from '../common/extensions.js'; import { InstallSpecificVersionOfExtensionAction, ConfigureWorkspaceRecommendedExtensionsAction, ConfigureWorkspaceFolderRecommendedExtensionsAction, SetColorThemeAction, SetFileIconThemeAction, SetProductIconThemeAction, ClearLanguageAction, ToggleAutoUpdateForExtensionAction, ToggleAutoUpdatesForPublisherAction, TogglePreReleaseExtensionAction, InstallAnotherVersionAction, InstallAction } from './extensionsActions.js'; @@ -81,6 +81,8 @@ import { IProductService } from '../../../../platform/product/common/productServ import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import product from '../../../../platform/product/common/product.js'; import { ExtensionGalleryResourceType, ExtensionGalleryServiceUrlConfigKey, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { ILanguageModelToolsService } from '../../chat/common/languageModelToolsService.js'; +import { SearchExtensionsTool, SearchExtensionsToolData } from '../common/searchExtensionsTool.js'; // Singletons registerSingleton(IExtensionsWorkbenchService, ExtensionsWorkbenchService, InstantiationType.Eager /* Auto updates extensions */); @@ -1963,6 +1965,21 @@ class TrustedPublishersInitializer implements IWorkbenchContribution { } } +class ExtensionToolsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'extensions.chat.toolsContribution'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + const searchExtensionsTool = instantiationService.createInstance(SearchExtensionsTool); + this._register(toolsService.registerToolData(SearchExtensionsToolData)); + this._register(toolsService.registerToolImplementation(SearchExtensionsToolData.id, searchExtensionsTool)); + } +} + const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); workbenchRegistry.registerWorkbenchContribution(ExtensionsContributions, LifecyclePhase.Restored); workbenchRegistry.registerWorkbenchContribution(StatusUpdater, LifecyclePhase.Eventually); @@ -1979,6 +1996,8 @@ if (isWeb) { workbenchRegistry.registerWorkbenchContribution(ExtensionStorageCleaner, LifecyclePhase.Eventually); } +registerWorkbenchContribution2(ExtensionToolsContribution.ID, ExtensionToolsContribution, WorkbenchPhase.AfterRestored); + // Running Extensions registerAction2(ShowRuntimeExtensionsAction); diff --git a/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts b/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts new file mode 100644 index 00000000000..3d587cef983 --- /dev/null +++ b/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { SortBy } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { EXTENSION_CATEGORIES } from '../../../../platform/extensions/common/extensions.js'; +import { CountTokensCallback, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../../chat/common/languageModelToolsService.js'; +import { ExtensionState, IExtensionsWorkbenchService } from '../common/extensions.js'; + +export const SearchExtensionsToolId = 'vscode_searchExtensions_internal'; + +export const SearchExtensionsToolData: IToolData = { + id: SearchExtensionsToolId, + toolReferenceName: 'extensions', + canBeReferencedInPrompt: true, + icon: ThemeIcon.fromId(Codicon.extensions.id), + supportsToolPicker: true, + displayName: localize('searchExtensionsTool.displayName', 'Search Extensions'), + modelDescription: localize('searchExtensionsTool.modelDescription', "This tool helps the model search for VS Code extensions from the Marketplace. The model should specify the category of extensions and keywords to search for. Note that the results may include false positives, so further filtering by reviewing the results is recommended."), + source: { type: 'internal' }, + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + description: 'The category of extensions to search for', + enum: EXTENSION_CATEGORIES, + }, + keywords: { + type: 'array', + items: { + type: 'string', + }, + description: 'The keywords to search for', + }, + }, + } +}; + +type InputParams = { + category?: string; + keywords?: string; +}; + +type ExtensionData = { + id: string; + name: string; + description: string; + installed: boolean; + installCount: number; + rating: number; + categories: readonly string[]; + tags: readonly string[]; +}; + +export class SearchExtensionsTool implements IToolImpl { + + constructor( + @IExtensionsWorkbenchService private readonly extensionWorkbenchService: IExtensionsWorkbenchService, + ) { } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, token: CancellationToken): Promise { + const params = invocation.parameters as InputParams; + if (!params.keywords?.length && !params.category) { + return { + content: [{ + kind: 'text', + value: localize('searchExtensionsTool.noInput', 'Please provide a category or keyword to search for.') + }] + }; + } + + const extensionsMap = new Map(); + const queryAndAddExtensions = async (text: string) => { + const extensions = await this.extensionWorkbenchService.queryGallery({ + text, + pageSize: 10, + sortBy: SortBy.InstallCount + }, token); + if (extensions.firstPage.length) { + for (const extension of extensions.firstPage) { + if (extension.deprecationInfo || extension.isMalicious) { + continue; + } + extensionsMap.set(extension.identifier.id.toLowerCase(), { + id: extension.identifier.id, + name: extension.displayName, + description: extension.description, + installed: extension.state === ExtensionState.Installed, + installCount: extension.installCount ?? 0, + rating: extension.rating ?? 0, + categories: extension.categories ?? [], + tags: extension.gallery?.tags ?? [] + }); + } + } + }; + + if (params.keywords?.length) { + for (const keyword of params.keywords ?? []) { + if (keyword === 'featured') { + await queryAndAddExtensions('featured'); + } else { + let text = params.category ? `category:"${params.category}"` : ''; + text = keyword ? `${text} ${keyword}`.trim() : text; + await queryAndAddExtensions(text); + } + } + } else { + await queryAndAddExtensions(`category:"${params.category}"`); + } + + const result = Array.from(extensionsMap.values()); + + return { + content: [{ + kind: 'text', + value: `Here are the list of extensions:\n${JSON.stringify(result)}\n. Use the following format to display extensions to the user because there is a renderer available to parse these extensions in this format and display them with all details. So, do not describe about the extensions to the user.\n\`\`\`vscode-extensions\nextensionId1,extensionId2\n\`\`\`\n.` + }], + toolResultDetails: { + input: JSON.stringify(params), + output: JSON.stringify(result.map(extension => extension.id)) + } + }; + } +} +