Path: blob/main/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts
5241 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 { distinct } from '../../../../../base/common/arrays.js';6import { IMatch, IFilter, or, matchesCamelCase, matchesWords, matchesBaseContiguousSubString } from '../../../../../base/common/filters.js';7import { Emitter } from '../../../../../base/common/event.js';8import { ILanguageModelsService, ILanguageModelProviderDescriptor, ILanguageModelChatMetadataAndIdentifier } from '../../../chat/common/languageModels.js';9import { localize } from '../../../../../nls.js';10import { Disposable } from '../../../../../base/common/lifecycle.js';11import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js';12import Severity from '../../../../../base/common/severity.js';1314export const MODEL_ENTRY_TEMPLATE_ID = 'model.entry.template';15export const VENDOR_ENTRY_TEMPLATE_ID = 'vendor.entry.template';16export const GROUP_ENTRY_TEMPLATE_ID = 'group.entry.template';1718const wordFilter = or(matchesBaseContiguousSubString, matchesWords);19const CAPABILITY_REGEX = /@capability:\s*([^\s]+)/gi;20const VISIBLE_REGEX = /@visible:\s*(true|false)/i;21const PROVIDER_REGEX = /@provider:\s*((".+?")|([^\s]+))/gi;2223export const SEARCH_SUGGESTIONS = {24FILTER_TYPES: [25'@provider:',26'@capability:',27'@visible:'28],29CAPABILITIES: [30'@capability:tools',31'@capability:vision',32'@capability:agent'33],34VISIBILITY: [35'@visible:true',36'@visible:false'37]38};3940export interface ILanguageModelProvider {41vendor: ILanguageModelProviderDescriptor;42group: ILanguageModelsProviderGroup;43}4445export interface ILanguageModel extends ILanguageModelChatMetadataAndIdentifier {46provider: ILanguageModelProvider;47visible: boolean;48}4950export interface ILanguageModelEntry {51type: 'model';52id: string;53templateId: string;54model: ILanguageModel;55providerMatches?: IMatch[];56modelNameMatches?: IMatch[];57modelIdMatches?: IMatch[];58capabilityMatches?: string[];59}6061export interface ILanguageModelGroupEntry {62type: 'group';63id: string;64label: string;65collapsed: boolean;66templateId: string;67}6869export interface ILanguageModelProviderEntry {70type: 'vendor';71id: string;72label: string;73templateId: string;74collapsed: boolean;75vendorEntry: ILanguageModelProvider;76}7778export interface IStatusEntry {79type: 'status';80id: string;81message: string;82severity: Severity;83}8485export interface ILanguageModelEntriesGroup {86group: ILanguageModelGroupEntry | ILanguageModelProviderEntry;87models: ILanguageModel[];88status?: IStatusEntry;89}9091export function isLanguageModelProviderEntry(entry: IViewModelEntry): entry is ILanguageModelProviderEntry {92return entry.type === 'vendor';93}9495export function isLanguageModelGroupEntry(entry: IViewModelEntry): entry is ILanguageModelGroupEntry {96return entry.type === 'group';97}9899export function isStatusEntry(entry: IViewModelEntry): entry is IStatusEntry {100return entry.type === 'status';101}102103export type IViewModelEntry = ILanguageModelEntry | ILanguageModelProviderEntry | ILanguageModelGroupEntry | IStatusEntry;104105export interface IViewModelChangeEvent {106at: number;107removed: number;108added: IViewModelEntry[];109}110111export const enum ChatModelGroup {112Vendor = 'vendor',113Visibility = 'visibility'114}115116export class ChatModelsViewModel extends Disposable {117118private readonly _onDidChange = this._register(new Emitter<IViewModelChangeEvent>());119readonly onDidChange = this._onDidChange.event;120121private readonly _onDidChangeGrouping = this._register(new Emitter<ChatModelGroup>());122readonly onDidChangeGrouping = this._onDidChangeGrouping.event;123124private languageModels: ILanguageModel[];125private languageModelGroupStatuses: Array<{ provider: ILanguageModelProvider; status: { severity: Severity; message: string } }> = [];126private languageModelGroups: ILanguageModelEntriesGroup[] = [];127128private readonly collapsedGroups = new Set<string>();129private searchValue: string = '';130private modelsSorted: boolean = false;131132private _groupBy: ChatModelGroup = ChatModelGroup.Vendor;133get groupBy(): ChatModelGroup { return this._groupBy; }134set groupBy(groupBy: ChatModelGroup) {135if (this._groupBy !== groupBy) {136this._groupBy = groupBy;137this.collapsedGroups.clear();138this.languageModelGroups = this.groupModels(this.languageModels);139this.doFilter();140this._onDidChangeGrouping.fire(groupBy);141}142}143144constructor(145@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,146) {147super();148this.languageModels = [];149this._register(this.languageModelsService.onDidChangeLanguageModels(vendor => this.refreshVendor(vendor)));150}151152private readonly _viewModelEntries: IViewModelEntry[] = [];153get viewModelEntries(): readonly IViewModelEntry[] {154return this._viewModelEntries;155}156private splice(at: number, removed: number, added: IViewModelEntry[]): void {157this._viewModelEntries.splice(at, removed, ...added);158if (this.selectedEntry) {159this.selectedEntry = this._viewModelEntries.find(entry => entry.id === this.selectedEntry?.id);160}161this._onDidChange.fire({ at, removed, added });162}163164selectedEntry: IViewModelEntry | undefined;165166public shouldRefilter(): boolean {167return !this.modelsSorted;168}169170filter(searchValue: string): readonly IViewModelEntry[] {171if (searchValue !== this.searchValue) {172this.searchValue = searchValue;173this.collapsedGroups.clear();174if (!this.modelsSorted) {175this.languageModelGroups = this.groupModels(this.languageModels);176}177this.doFilter();178}179return this.viewModelEntries;180}181182private doFilter(): void {183const viewModelEntries: IViewModelEntry[] = [];184const shouldShowGroupHeaders = this.languageModelGroups.length > 1;185186for (const group of this.languageModelGroups) {187if (this.collapsedGroups.has(group.group.id)) {188group.group.collapsed = true;189if (shouldShowGroupHeaders) {190viewModelEntries.push(group.group);191}192continue;193}194195const groupEntries: IViewModelEntry[] = [];196if (group.status) {197groupEntries.push(group.status);198}199200groupEntries.push(...this.filterModels(group.models, this.searchValue));201202if (groupEntries.length > 0) {203group.group.collapsed = false;204if (shouldShowGroupHeaders) {205viewModelEntries.push(group.group);206}207viewModelEntries.push(...groupEntries);208}209}210this.splice(0, this._viewModelEntries.length, viewModelEntries);211}212213private filterModels(modelEntries: ILanguageModel[], searchValue: string): IViewModelEntry[] {214let visible: boolean | undefined;215216const visibleMatches = VISIBLE_REGEX.exec(searchValue);217if (visibleMatches && visibleMatches[1]) {218visible = visibleMatches[1].toLowerCase() === 'true';219searchValue = searchValue.replace(VISIBLE_REGEX, '');220}221222const providerNames: string[] = [];223let providerMatch: RegExpExecArray | null;224PROVIDER_REGEX.lastIndex = 0;225while ((providerMatch = PROVIDER_REGEX.exec(searchValue)) !== null) {226const providerName = providerMatch[2] ? providerMatch[2].substring(1, providerMatch[2].length - 1) : providerMatch[3];227providerNames.push(providerName);228}229if (providerNames.length > 0) {230searchValue = searchValue.replace(PROVIDER_REGEX, '');231}232233const capabilities: string[] = [];234let capabilityMatch: RegExpExecArray | null;235CAPABILITY_REGEX.lastIndex = 0;236while ((capabilityMatch = CAPABILITY_REGEX.exec(searchValue)) !== null) {237capabilities.push(capabilityMatch[1].toLowerCase());238}239if (capabilities.length > 0) {240searchValue = searchValue.replace(CAPABILITY_REGEX, '');241}242243const quoteAtFirstChar = searchValue.charAt(0) === '"';244const quoteAtLastChar = searchValue.charAt(searchValue.length - 1) === '"';245const completeMatch = quoteAtFirstChar && quoteAtLastChar;246if (quoteAtFirstChar) {247searchValue = searchValue.substring(1);248}249if (quoteAtLastChar) {250searchValue = searchValue.substring(0, searchValue.length - 1);251}252searchValue = searchValue.trim();253254const result: IViewModelEntry[] = [];255const words = searchValue.split(' ');256const lowerProviders = providerNames.map(p => p.toLowerCase().trim());257258for (const modelEntry of modelEntries) {259if (visible !== undefined) {260if (modelEntry.visible !== visible) {261continue;262}263}264265if (lowerProviders.length > 0) {266const matchesProvider = lowerProviders.some(provider =>267modelEntry.provider.vendor.vendor.toLowerCase() === provider ||268modelEntry.provider.vendor.displayName.toLowerCase() === provider269);270if (!matchesProvider) {271continue;272}273}274275// Filter by capabilities276let matchedCapabilities: string[] = [];277if (capabilities.length > 0) {278if (!modelEntry.metadata.capabilities) {279continue;280}281let matchesAll = true;282for (const capability of capabilities) {283const matchedForThisCapability = this.getMatchingCapabilities(modelEntry, capability);284if (matchedForThisCapability.length === 0) {285matchesAll = false;286break;287}288matchedCapabilities.push(...matchedForThisCapability);289}290if (!matchesAll) {291continue;292}293matchedCapabilities = distinct(matchedCapabilities);294}295296// Filter by text297let modelMatches: ModelItemMatches | undefined;298if (searchValue) {299modelMatches = new ModelItemMatches(modelEntry, searchValue, words, completeMatch);300if (!modelMatches.modelNameMatches && !modelMatches.modelIdMatches && !modelMatches.providerMatches && !modelMatches.capabilityMatches) {301continue;302}303}304305const modelId = this.getModelId(modelEntry);306result.push({307type: 'model',308id: modelId,309templateId: MODEL_ENTRY_TEMPLATE_ID,310model: modelEntry,311modelNameMatches: modelMatches?.modelNameMatches || undefined,312modelIdMatches: modelMatches?.modelIdMatches || undefined,313providerMatches: modelMatches?.providerMatches || undefined,314capabilityMatches: matchedCapabilities.length ? matchedCapabilities : undefined,315});316}317return result;318}319320private getMatchingCapabilities(modelEntry: ILanguageModel, capability: string): string[] {321const matchedCapabilities: string[] = [];322if (!modelEntry.metadata.capabilities) {323return matchedCapabilities;324}325326switch (capability) {327case 'tools':328case 'toolcalling':329if (modelEntry.metadata.capabilities.toolCalling === true) {330matchedCapabilities.push('toolCalling');331}332break;333case 'vision':334if (modelEntry.metadata.capabilities.vision === true) {335matchedCapabilities.push('vision');336}337break;338case 'agent':339case 'agentmode':340if (modelEntry.metadata.capabilities.agentMode === true) {341matchedCapabilities.push('agentMode');342}343break;344default:345// Check edit tools346if (modelEntry.metadata.capabilities.editTools) {347for (const tool of modelEntry.metadata.capabilities.editTools) {348if (tool.toLowerCase().includes(capability)) {349matchedCapabilities.push(tool);350}351}352}353break;354}355return matchedCapabilities;356}357358private groupModels(languageModels: ILanguageModel[]): ILanguageModelEntriesGroup[] {359const result: ILanguageModelEntriesGroup[] = [];360if (this.groupBy === ChatModelGroup.Visibility) {361const visible = [], hidden = [];362for (const model of languageModels) {363if (model.visible) {364visible.push(model);365} else {366hidden.push(model);367}368}369result.push({370group: {371type: 'group',372id: 'visible',373label: localize('visible', "Visible"),374templateId: GROUP_ENTRY_TEMPLATE_ID,375collapsed: this.collapsedGroups.has('visible')376},377models: visible378});379result.push({380group: {381type: 'group',382id: 'hidden',383label: localize('hidden', "Hidden"),384templateId: GROUP_ENTRY_TEMPLATE_ID,385collapsed: this.collapsedGroups.has('hidden'),386},387models: hidden388});389}390else if (this.groupBy === ChatModelGroup.Vendor) {391for (const model of languageModels) {392const groupId = this.getProviderGroupId(model.provider.group);393let group = result.find(group => group.group.id === groupId);394if (!group) {395group = {396group: this.createLanguageModelProviderEntry(model.provider),397models: [],398};399result.push(group);400}401group.models.push(model);402}403for (const statusGroup of this.languageModelGroupStatuses) {404const groupId = this.getProviderGroupId(statusGroup.provider.group);405let group = result.find(group => group.group.id === groupId);406if (!group) {407group = {408group: this.createLanguageModelProviderEntry(statusGroup.provider),409models: [],410};411result.push(group);412}413group.status = {414id: `status.${group.group.id}`,415type: 'status',416...statusGroup.status,417};418}419result.sort((a, b) => {420if (a.models[0]?.provider.vendor.isDefault) { return -1; }421if (b.models[0]?.provider.vendor.isDefault) { return 1; }422return a.group.label.localeCompare(b.group.label);423});424}425for (const group of result) {426group.models.sort((a, b) => {427if (a.provider.vendor.isDefault && b.provider.vendor.isDefault) {428return a.metadata.name.localeCompare(b.metadata.name);429}430if (a.provider.vendor.isDefault) { return -1; }431if (b.provider.vendor.isDefault) { return 1; }432if (a.provider.group.name === b.provider.group.name) {433return a.metadata.name.localeCompare(b.metadata.name);434}435return a.provider.group.name.localeCompare(b.provider.group.name);436});437}438this.modelsSorted = true;439return result;440}441442private createLanguageModelProviderEntry(provider: ILanguageModelProvider): ILanguageModelProviderEntry {443const id = this.getProviderGroupId(provider.group);444return {445type: 'vendor',446id,447label: provider.group.name,448templateId: VENDOR_ENTRY_TEMPLATE_ID,449collapsed: this.collapsedGroups.has(id),450vendorEntry: {451group: provider.group,452vendor: provider.vendor453},454};455}456457getVendors(): ILanguageModelProviderDescriptor[] {458return [...this.languageModelsService.getVendors()].sort((a, b) => {459if (a.isDefault) { return -1; }460if (b.isDefault) { return 1; }461return a.displayName.localeCompare(b.displayName);462});463}464465async refresh(): Promise<void> {466await this.languageModelsService.selectLanguageModels({});467await this.refreshAllVendors();468}469470private async refreshAllVendors(): Promise<void> {471this.languageModels = [];472this.languageModelGroupStatuses = [];473for (const vendor of this.getVendors()) {474this.addVendorModels(vendor);475}476this.languageModelGroups = this.groupModels(this.languageModels);477this.doFilter();478}479480private refreshVendor(vendorId: string): void {481const vendor = this.getVendors().find(v => v.vendor === vendorId);482if (!vendor) {483return;484}485486// Remove existing models for this vendor487this.languageModels = this.languageModels.filter(m => m.provider.vendor.vendor !== vendorId);488this.languageModelGroupStatuses = this.languageModelGroupStatuses.filter(s => s.provider.vendor.vendor !== vendorId);489490// Add updated models for this vendor491this.addVendorModels(vendor);492this.languageModelGroups = this.groupModels(this.languageModels);493this.doFilter();494}495496private addVendorModels(vendor: ILanguageModelProviderDescriptor): void {497const models: ILanguageModel[] = [];498const languageModelsGroups = this.languageModelsService.getLanguageModelGroups(vendor.vendor);499for (const group of languageModelsGroups) {500const provider: ILanguageModelProvider = {501group: group.group ?? {502vendor: vendor.vendor,503name: vendor.displayName504},505vendor506};507if (group.status) {508this.languageModelGroupStatuses.push({509provider,510status: {511message: group.status.message,512severity: group.status.severity513}514});515}516for (const identifier of group.modelIdentifiers) {517const metadata = this.languageModelsService.lookupLanguageModel(identifier);518if (!metadata) {519continue;520}521if (vendor.isDefault && metadata.id === 'auto') {522continue;523}524models.push({525identifier,526metadata,527provider,528visible: metadata.isUserSelectable ?? false,529});530}531}532this.languageModels.push(...models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)));533}534535toggleVisibility(model: ILanguageModelEntry): void {536const newVisibility = !model.model.visible;537this.languageModelsService.updateModelPickerPreference(model.model.identifier, newVisibility);538const metadata = this.languageModelsService.lookupLanguageModel(model.model.identifier);539const index = this.viewModelEntries.indexOf(model);540if (metadata && index !== -1) {541model.model.visible = newVisibility;542model.model.metadata = metadata;543model.id = this.getModelId(model.model);544if (this.groupBy === ChatModelGroup.Visibility) {545this.modelsSorted = false;546}547this.splice(index, 1, [model]);548}549}550551setModelsVisibility(models: ILanguageModelEntry[], visible: boolean): void {552for (const model of models) {553this.languageModelsService.updateModelPickerPreference(model.model.identifier, visible);554model.model.visible = visible;555}556// Refresh to update the UI557this.languageModelGroups = this.groupModels(this.languageModels);558this.doFilter();559}560561setGroupVisibility(group: ILanguageModelProviderEntry | ILanguageModelGroupEntry, visible: boolean): void {562const models = this.getModelsForGroup(group);563for (const model of models) {564this.languageModelsService.updateModelPickerPreference(model.identifier, visible);565model.visible = visible;566}567// Refresh to update the UI568this.languageModelGroups = this.groupModels(this.languageModels);569this.doFilter();570}571572getModelsForGroup(group: ILanguageModelProviderEntry | ILanguageModelGroupEntry): ILanguageModel[] {573if (isLanguageModelProviderEntry(group)) {574return this.languageModels.filter(m =>575this.getProviderGroupId(m.provider.group) === group.id576);577} else {578// Group by visibility579return this.languageModels.filter(m =>580(group.id === 'visible' && m.visible) ||581(group.id === 'hidden' && !m.visible)582);583}584}585586private getModelId(modelEntry: ILanguageModel): string {587return `${modelEntry.provider.group.name}.${modelEntry.identifier}.${modelEntry.metadata.version}-visible:${modelEntry.visible}`;588}589590private getProviderGroupId(group: ILanguageModelsProviderGroup): string {591return `${group.vendor}-${group.name}`;592}593594toggleCollapsed(viewModelEntry: IViewModelEntry): void {595const id = isLanguageModelGroupEntry(viewModelEntry) ? viewModelEntry.id : isLanguageModelProviderEntry(viewModelEntry) ? viewModelEntry.id : undefined;596if (!id) {597return;598}599this.selectedEntry = viewModelEntry;600if (!this.collapsedGroups.delete(id)) {601this.collapsedGroups.add(id);602}603this.doFilter();604}605606collapseAll(): void {607this.collapsedGroups.clear();608for (const entry of this.viewModelEntries) {609if (isLanguageModelProviderEntry(entry) || isLanguageModelGroupEntry(entry)) {610this.collapsedGroups.add(entry.id);611}612}613this.doFilter();614}615616getConfiguredVendors(): ILanguageModelProvider[] {617const result: ILanguageModelProvider[] = [];618const seenVendors = new Set<string>();619for (const modelEntry of this.languageModels) {620if (!seenVendors.has(modelEntry.provider.group.name)) {621seenVendors.add(modelEntry.provider.group.name);622result.push(modelEntry.provider);623}624}625return result;626}627}628629class ModelItemMatches {630631readonly modelNameMatches: IMatch[] | null = null;632readonly modelIdMatches: IMatch[] | null = null;633readonly providerMatches: IMatch[] | null = null;634readonly capabilityMatches: IMatch[] | null = null;635636constructor(modelEntry: ILanguageModel, searchValue: string, words: string[], completeMatch: boolean) {637if (!completeMatch) {638// Match against model name639this.modelNameMatches = modelEntry.metadata.name ?640this.matches(searchValue, modelEntry.metadata.name, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words) :641null;642643this.modelIdMatches = this.matches(searchValue, modelEntry.metadata.id, or(matchesWords, matchesCamelCase), words);644645// Match against vendor display name646this.providerMatches = this.matches(searchValue, modelEntry.provider.group.name, (word, wordToMatchAgainst) => matchesWords(word, wordToMatchAgainst, true), words);647648// Match against capabilities649if (modelEntry.metadata.capabilities) {650const capabilityStrings: string[] = [];651if (modelEntry.metadata.capabilities.toolCalling) {652capabilityStrings.push('tools', 'toolCalling');653}654if (modelEntry.metadata.capabilities.vision) {655capabilityStrings.push('vision');656}657if (modelEntry.metadata.capabilities.agentMode) {658capabilityStrings.push('agent', 'agentMode');659}660if (modelEntry.metadata.capabilities.editTools) {661capabilityStrings.push(...modelEntry.metadata.capabilities.editTools);662}663664const capabilityString = capabilityStrings.join(' ');665if (capabilityString) {666this.capabilityMatches = this.matches(searchValue, capabilityString, or(matchesWords, matchesCamelCase), words);667}668}669}670}671672private matches(searchValue: string | null, wordToMatchAgainst: string, wordMatchesFilter: IFilter, words: string[]): IMatch[] | null {673let matches = searchValue ? wordFilter(searchValue, wordToMatchAgainst) : null;674if (!matches) {675matches = this.matchesWords(words, wordToMatchAgainst, wordMatchesFilter);676}677if (matches) {678matches = this.filterAndSort(matches);679}680return matches;681}682683private matchesWords(words: string[], wordToMatchAgainst: string, wordMatchesFilter: IFilter): IMatch[] | null {684let matches: IMatch[] | null = [];685for (const word of words) {686const wordMatches = wordMatchesFilter(word, wordToMatchAgainst);687if (wordMatches) {688matches = [...(matches || []), ...wordMatches];689} else {690matches = null;691break;692}693}694return matches;695}696697private filterAndSort(matches: IMatch[]): IMatch[] {698return distinct(matches, (a => a.start + '.' + a.end))699.filter(match => !matches.some(m => !(m.start === match.start && m.end === match.end) && (m.start <= match.start && m.end >= match.end)))700.sort((a, b) => a.start - b.start);701}702}703704705