Path: blob/main/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts
13406 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 { onUnexpectedError } from '../../../../../base/common/errors.js';6import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';7import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js';8import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';9import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js';10import { IFileService } from '../../../../../platform/files/common/files.js';11import { ILabelService } from '../../../../../platform/label/common/label.js';12import { IProductService } from '../../../../../platform/product/common/productService.js';13import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';14import { IPathService } from '../../../../services/path/common/pathService.js';15import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js';16import { ICustomizationHarnessService, ICustomizationItemProvider, IHarnessDescriptor } from '../../common/customizationHarnessService.js';17import { IAgentPluginService } from '../../common/plugins/agentPluginService.js';18import { PromptsType } from '../../common/promptSyntax/promptTypes.js';19import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js';20import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ProviderCustomizationItemSource } from './aiCustomizationItemSource.js';21import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js';2223/**24* The set of sections whose items are sourced from the customization25* harness pipeline (extension-contributed providers, sync providers,26* and the prompts-service fallback). McpServers / Plugins / Models27* have their own dedicated services and are not modeled here.28*/29export const ITEMS_MODEL_SECTIONS = [30AICustomizationManagementSection.Agents,31AICustomizationManagementSection.Skills,32AICustomizationManagementSection.Instructions,33AICustomizationManagementSection.Prompts,34AICustomizationManagementSection.Hooks,35] as const;3637export type ItemsModelSection = typeof ITEMS_MODEL_SECTIONS[number];3839export const IAICustomizationItemsModel = createDecorator<IAICustomizationItemsModel>('aiCustomizationItemsModel');4041/**42* Single source of truth for the items rendered by the AI Customizations43* editor and observed by sidebar surfaces (counts/badges).44*45* The model owns the per-active-harness `ProviderCustomizationItemSource`46* cache and exposes the unfiltered, normalized list of items per section.47* Both the editor and any sidebar surface read from these observables so48* there is exactly one discovery path for customizations.49*/50export interface IAICustomizationItemsModel {51readonly _serviceBrand: undefined;5253/**54* Returns an observable of the unfiltered, normalized list items for the55* given prompts-based section under the currently active harness.56*/57getItems(section: ItemsModelSection): IObservable<readonly IAICustomizationListItem[]>;5859/**60* Returns the live `ProviderCustomizationItemSource` for the active harness.61* Editor consumers may need this to access provider-level affordances62* (e.g. debug reporting). The returned source is reused across the63* lifetime of the active descriptor.64*/65getActiveItemSource(): IAICustomizationItemSource;6667/**68* Convenience: an observable of the count for the given section.69*/70getCount(section: ItemsModelSection): IObservable<number>;7172/**73* The fallback item provider used when the active descriptor has neither74* an `itemProvider` nor a `syncProvider`. Exposed for the debug report.75*/76getPromptsServiceItemProvider(): ICustomizationItemProvider;7778/**79* Resolves once the most recent fetch for `section` has settled. Useful for80* tests / fixtures that need rendered output to reflect at least one fetch.81* Calling this also marks the section as observed (i.e. starts a fetch if82* none has been kicked off yet).83*/84whenSectionLoaded(section: ItemsModelSection): Promise<void>;85}8687export class AICustomizationItemsModel extends Disposable implements IAICustomizationItemsModel {88declare readonly _serviceBrand: undefined;8990private readonly itemNormalizer: AICustomizationItemNormalizer;91private readonly promptsServiceItemProvider: PromptsServiceCustomizationItemProvider;9293/**94* Cached source per active descriptor. Keyed by descriptor reference (not id) so that95* an external harness re-registering under the same id (e.g. extension reload) gets a96* fresh source bound to the new provider. Pruned when its descriptor is no longer97* present in `availableHarnesses`.98*/99private readonly sourceCache = new Map<IHarnessDescriptor, IAICustomizationItemSource>();100101private readonly perSection = new Map<ItemsModelSection, ISettableObservable<readonly IAICustomizationListItem[]>>();102private readonly perSectionCount = new Map<ItemsModelSection, IObservable<number>>();103private readonly fetchSeq = new Map<ItemsModelSection, number>();104/** Promise of the most recent fetch per section (resolves regardless of stale-discard). */105private readonly perSectionPending = new Map<ItemsModelSection, Promise<void>>();106/**107* Sections that have been observed at least once. The model only fetches on108* demand: first `getItems`/`getCount` for a section triggers an initial fetch,109* and subsequent harness/source/workspace change events refetch only sections110* that have already been read. This avoids 5x provider enumeration on startup.111*/112private readonly observedSections = new Set<ItemsModelSection>();113114constructor(115@ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService,116@IPromptsService private readonly promptsService: IPromptsService,117@IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService,118@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,119@ILabelService labelService: ILabelService,120@IAgentPluginService agentPluginService: IAgentPluginService,121@IProductService productService: IProductService,122@IFileService private readonly fileService: IFileService,123@IPathService private readonly pathService: IPathService,124) {125super();126127this.itemNormalizer = new AICustomizationItemNormalizer(128workspaceContextService,129workspaceService,130labelService,131agentPluginService,132productService,133);134this.promptsServiceItemProvider = new PromptsServiceCustomizationItemProvider(135() => this.harnessService.getActiveDescriptor(),136this.promptsService,137this.workspaceService,138productService,139);140141for (const section of ITEMS_MODEL_SECTIONS) {142const items = observableValue<readonly IAICustomizationListItem[]>(`aiCustomizationItems:${section}`, []);143this.perSection.set(section, items);144this.perSectionCount.set(section, derived(reader => items.read(reader).length));145this.fetchSeq.set(section, 0);146}147148// Re-bind to the active source whenever the active harness or the set of available149// harnesses changes (a new external provider may have registered for the already-150// active id), prune the source cache, and refetch any observed sections.151const sourceChangeListener = this._register(new MutableDisposable());152this._register(autorun(reader => {153const available = this.harnessService.availableHarnesses.read(reader);154this.harnessService.activeHarness.read(reader);155this.pruneSourceCache(available);156const descriptor = this.harnessService.getActiveDescriptor();157const source = this.getOrCreateSource(descriptor);158sourceChangeListener.value = source.onDidChange(() => this.refetchObserved(source));159this.refetchObserved(source);160}));161162// Workspace folder changes / active project root changes affect the items the163// prompts service surfaces (e.g. workspace vs. user classification).164this._register(workspaceContextService.onDidChangeWorkspaceFolders(() => this.refetchObserved(this.getActiveItemSource())));165this._register(autorun(reader => {166this.workspaceService.activeProjectRoot.read(reader);167this.refetchObserved(this.getActiveItemSource());168}));169}170171getItems(section: ItemsModelSection): IObservable<readonly IAICustomizationListItem[]> {172this.markObserved(section);173return this.perSection.get(section)!;174}175176getCount(section: ItemsModelSection): IObservable<number> {177this.markObserved(section);178return this.perSectionCount.get(section)!;179}180181getActiveItemSource(): IAICustomizationItemSource {182return this.getOrCreateSource(this.harnessService.getActiveDescriptor());183}184185getPromptsServiceItemProvider(): ICustomizationItemProvider {186return this.promptsServiceItemProvider;187}188189whenSectionLoaded(section: ItemsModelSection): Promise<void> {190this.markObserved(section);191return this.perSectionPending.get(section) ?? Promise.resolve();192}193194private markObserved(section: ItemsModelSection): void {195if (this.observedSections.has(section) || this._store.isDisposed) {196return;197}198this.observedSections.add(section);199this.refetchSection(section, this.getActiveItemSource());200}201202private getOrCreateSource(descriptor: IHarnessDescriptor): IAICustomizationItemSource {203const cached = this.sourceCache.get(descriptor);204if (cached) {205return cached;206}207const itemProvider = descriptor.itemProvider ?? (descriptor.syncProvider ? undefined : this.promptsServiceItemProvider);208const source = new ProviderCustomizationItemSource(209itemProvider,210descriptor.syncProvider,211this.promptsService,212this.workspaceService,213this.fileService,214this.pathService,215this.itemNormalizer,216);217this.sourceCache.set(descriptor, source);218return source;219}220221private pruneSourceCache(available: readonly IHarnessDescriptor[]): void {222const live = new Set(available);223for (const descriptor of this.sourceCache.keys()) {224if (!live.has(descriptor)) {225this.sourceCache.delete(descriptor);226}227}228}229230private refetchObserved(source: IAICustomizationItemSource): void {231for (const section of this.observedSections) {232this.refetchSection(section, source);233}234}235236private refetchSection(section: ItemsModelSection, source: IAICustomizationItemSource): void {237const seq = (this.fetchSeq.get(section) ?? 0) + 1;238this.fetchSeq.set(section, seq);239const promptType = sectionToPromptType(section);240const observable = this.perSection.get(section)!;241const pending = source.fetchItems(promptType).then(items => {242if (this._store.isDisposed) {243return;244}245// Discard stale results (a newer fetch superseded this one).246if (this.fetchSeq.get(section) !== seq) {247return;248}249// Discard if the active source changed mid-fetch.250if (this.getActiveItemSource() !== source) {251return;252}253observable.set(items, undefined);254}, e => {255if (this._store.isDisposed) {256return;257}258onUnexpectedError(e);259});260this.perSectionPending.set(section, pending);261}262}263264function sectionToPromptType(section: ItemsModelSection): PromptsType {265switch (section) {266case AICustomizationManagementSection.Agents: return PromptsType.agent;267case AICustomizationManagementSection.Skills: return PromptsType.skill;268case AICustomizationManagementSection.Instructions: return PromptsType.instructions;269case AICustomizationManagementSection.Hooks: return PromptsType.hook;270case AICustomizationManagementSection.Prompts:271default: return PromptsType.prompt;272}273}274275registerSingleton(IAICustomizationItemsModel, AICustomizationItemsModel, InstantiationType.Delayed);276277278