From 9505cf1bfb8328da93091557554d2a0dcaf5e1b3 Mon Sep 17 00:00:00 2001 From: Shane Lamb Date: Thu, 27 Mar 2025 23:20:53 +1000 Subject: [PATCH] added selector for default model Added a button to choose the default model from a list of available models, in the settings tab. The list is populated from cache. --- src/connection-models.ts | 46 +++++++++++++++++++---------- src/open-ai.ts | 4 +-- src/registry.ts | 4 +++ src/settings-tab/connections.ts | 4 +-- src/settings-tab/index.ts | 50 +++++++++++++++----------------- src/settings-tab/model-picker.ts | 35 ++++++++++++++++++++++ src/settings.ts | 9 +----- styles.css | 8 +++++ 8 files changed, 107 insertions(+), 53 deletions(-) create mode 100644 src/settings-tab/model-picker.ts diff --git a/src/connection-models.ts b/src/connection-models.ts index eca09ba..a0fc7b3 100644 --- a/src/connection-models.ts +++ b/src/connection-models.ts @@ -1,17 +1,44 @@ import { LlmConnectionSettings } from './settings' import { getAvailableOpenaiModels } from './open-ai' +import { modelCacheUpdated } from './registry' -const modelToConnectionIdCache = new Map() +const modelToConnectionCache = new Map() -function getConnectionId(connection: LlmConnectionSettings) { +export interface ConnectionModel { + connection: LlmConnectionSettings + model: string +} + +export function getCachedConnectionModels(connections: LlmConnectionSettings[]): ConnectionModel[] { + const models: ConnectionModel[] = [] + for (const connection of connections) { + const connectionId = getConnectionId(connection) + for (const [model, id] of modelToConnectionCache) { + if (id === connectionId) { + models.push({ connection, model }) + } + } + } + return models +} + +export function getConnectionId(connection: LlmConnectionSettings) { return connection.type + connection.baseUrl } +export async function getAvailableModelsAndUpdateCache(connection: LlmConnectionSettings) { + let models = await getAvailableOpenaiModels(connection) + for (const model of models) { + modelToConnectionCache.set(model, getConnectionId(connection)) + } + modelCacheUpdated.emit('change') +} + function resolveCachedConnectionForModel( connections: LlmConnectionSettings[], model: string, ): LlmConnectionSettings | null { - const cachedConnectionId = modelToConnectionIdCache.get(model) + const cachedConnectionId = modelToConnectionCache.get(model) if (cachedConnectionId) { const matching = connections.find((connection) => getConnectionId(connection) === cachedConnectionId) if (matching) { @@ -30,18 +57,7 @@ export async function resolveConnectionForModel( return cached } - const promises = connections.map((connection) => - getAvailableOpenaiModels(connection) - .then((models) => { - if (!models) { - return - } - for (const model of models) { - modelToConnectionIdCache.set(model, getConnectionId(connection)) - } - }) - .catch(() => {}), - ) + const promises = connections.map((connection) => getAvailableModelsAndUpdateCache(connection).catch(() => {})) await Promise.all(promises) return resolveCachedConnectionForModel(connections, model) diff --git a/src/open-ai.ts b/src/open-ai.ts index 2eb1505..297624f 100644 --- a/src/open-ai.ts +++ b/src/open-ai.ts @@ -22,7 +22,7 @@ export interface OpenaiContent { } } -export async function getAvailableOpenaiModels(settings: LlmConnectionSettings): Promise { +export async function getAvailableOpenaiModels(settings: LlmConnectionSettings): Promise { const response = await fetch(`${settings.baseUrl}/v1/models`, { headers: { 'Content-Type': 'application/json', @@ -33,7 +33,7 @@ export async function getAvailableOpenaiModels(settings: LlmConnectionSettings): await throwOnBadResponse(response) const data: any = await response.json() - return data?.data.map((model: any) => model.id) + return data.data.map((model: any) => model.id) } export class OpenaiChatCompletionStream extends EventEmitter { diff --git a/src/registry.ts b/src/registry.ts index e9b4c90..3908db9 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -25,3 +25,7 @@ export interface ILlmDocsPlugin { let llmDocsPlugin: ILlmDocsPlugin export const getLlmDocsPlugin = () => llmDocsPlugin export const setLlmDocsPlugin = (plugin: ILlmDocsPlugin) => (llmDocsPlugin = plugin) + +export const modelCacheUpdated = new EventEmitter() + +export const modelSelected = new EventEmitter() diff --git a/src/settings-tab/connections.ts b/src/settings-tab/connections.ts index 2af8f60..0772290 100644 --- a/src/settings-tab/connections.ts +++ b/src/settings-tab/connections.ts @@ -1,6 +1,6 @@ import { Notice, Setting } from 'obsidian' -import { getAvailableOpenaiModels } from '../open-ai' import LlmDocsPlugin from '../main' +import { getAvailableModelsAndUpdateCache } from '../connection-models' export function addConnectionsSettings(containerEl: HTMLElement, plugin: LlmDocsPlugin, redraw: () => void) { new Setting(containerEl) @@ -49,7 +49,7 @@ export function addConnectionsSettings(containerEl: HTMLElement, plugin: LlmDocs button.setButtonText('Test').onClick(async () => { button.setDisabled(true) try { - await getAvailableOpenaiModels(connection) + await getAvailableModelsAndUpdateCache(connection) new Notice('Connection success!') } catch (error) { new Notice(error) diff --git a/src/settings-tab/index.ts b/src/settings-tab/index.ts index 74597f9..07f2aaa 100644 --- a/src/settings-tab/index.ts +++ b/src/settings-tab/index.ts @@ -1,7 +1,9 @@ import { App, PluginSettingTab, Setting, TextComponent } from 'obsidian' -import { DocOpenMethods, openaiModels } from '../settings' +import { DocOpenMethods } from '../settings' import LlmDocsPlugin from '../main' import { addConnectionsSettings } from './connections' +import { modelCacheUpdated, modelSelected } from '../registry' +import { ModelPickerModal } from './model-picker' export class SettingsTab extends PluginSettingTab { plugin: LlmDocsPlugin @@ -9,6 +11,13 @@ export class SettingsTab extends PluginSettingTab { constructor(app: App, plugin: LlmDocsPlugin) { super(app, plugin) this.plugin = plugin + modelCacheUpdated.on('change', () => this.display()) + } + + hide() { + super.hide() + modelCacheUpdated.removeAllListeners() + modelSelected.removeAllListeners() } display(): void { @@ -67,38 +76,27 @@ export class SettingsTab extends PluginSettingTab { } addDefaultModelSetting(containerEl: HTMLElement) { - const initialValue = this.plugin.settings.defaults.model - const initialValueIsOther = !Object.keys(openaiModels).includes(initialValue) let textComponent: TextComponent + + const save = async (value: string) => { + this.plugin.settings.defaults.model = value + await this.plugin.saveSettings() + } + new Setting(containerEl) .setName('Default model') .setDesc('The default LLM model variant to use for new LLM documents') - .addDropdown((dropdown) => { - dropdown - .addOptions({ - ...openaiModels, - other: 'Custom', - }) - .setValue(initialValueIsOther ? 'other' : initialValue) - .onChange(async (value) => { - if (value === 'other') { - textComponent.setDisabled(false) - textComponent.inputEl.focus() - } else { - textComponent.setDisabled(true) - textComponent.setValue(value) - this.plugin.settings.defaults.model = value - await this.plugin.saveSettings() - } + .addButton((button) => { + button.setButtonText('Select').onClick(() => { + new ModelPickerModal(this.app, this.plugin.settings.connections).open() + modelSelected.on('change', async (model) => { + textComponent.setValue(model) + await save(model) }) + }) }) .addText((text) => { - text.setValue(initialValue) - .onChange(async (value) => { - this.plugin.settings.defaults.model = value - await this.plugin.saveSettings() - }) - .setDisabled(!initialValueIsOther) + text.setValue(this.plugin.settings.defaults.model).onChange(async (value) => save(value)) textComponent = text }) } diff --git a/src/settings-tab/model-picker.ts b/src/settings-tab/model-picker.ts new file mode 100644 index 0000000..a8d5372 --- /dev/null +++ b/src/settings-tab/model-picker.ts @@ -0,0 +1,35 @@ +import { App, SuggestModal } from 'obsidian' +import { LlmConnectionSettings } from '../settings' +import { ConnectionModel, getCachedConnectionModels, getConnectionId } from '../connection-models' +import { modelSelected } from '../registry' + +export class ModelPickerModal extends SuggestModal { + private models: ConnectionModel[] + + constructor(app: App, connections: LlmConnectionSettings[]) { + super(app) + const collator = new Intl.Collator() + this.models = getCachedConnectionModels(connections).sort((a, b) => collator.compare(sortKey(a), sortKey(b))) + this.emptyStateText = 'No results. Click "Test" on each connection to populate the list of models.' + } + + getSuggestions(query: string): ConnectionModel[] { + return this.models.filter((model) => model.model.toLowerCase().includes(query.toLowerCase())) + } + + renderSuggestion({ connection, model }: ConnectionModel, el: HTMLElement) { + el.createEl('div', { text: model, cls: 'llmdocs-suggest-item-title' }) + el.createEl('small', { + text: `${connection.type} @ ${connection.baseUrl}`, + cls: 'llmdocs-suggest-item-subtext', + }) + } + + onChooseSuggestion(model: ConnectionModel, evt: MouseEvent | KeyboardEvent) { + modelSelected.emit('change', model.model) + } +} + +function sortKey(model: ConnectionModel): string { + return getConnectionId(model.connection) + '_' + model.model +} diff --git a/src/settings.ts b/src/settings.ts index 8e38ef0..943458d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -18,24 +18,17 @@ export interface DefaultsSettings { docOpenMethod: DocOpenMethods } -export const openaiModels = { - 'gpt-4o': 'GPT-4o', - 'gpt-4o-mini': 'GPT-4o mini', -} - export interface LlmConnectionSettings { type: 'OpenAI' // 'Anthropic' and other types in future baseUrl: string apiKey: string } -const defaultModel: keyof typeof openaiModels = 'gpt-4o' - export const defaultPluginSettings: PluginSettings = { docsDir: 'LLM', connections: [], defaults: { - model: defaultModel, + model: 'gpt-4o', systemPrompt: '', docOpenMethod: DocOpenMethods.tab, }, diff --git a/styles.css b/styles.css index fe61ff6..54b96f7 100644 --- a/styles.css +++ b/styles.css @@ -58,3 +58,11 @@ If your plugin does not need CSS, delete this file. opacity: 50%; animation: rotate90 2s steps(1) infinite; } + +.llmdocs-suggest-item-title { + font-weight: 600; +} + +.llmdocs-suggest-item-subtext { + color: var(--text-muted); +}