diff --git a/src/connection-models.ts b/src/connection-models.ts index c6ee1d3..3af2523 100644 --- a/src/connection-models.ts +++ b/src/connection-models.ts @@ -2,35 +2,7 @@ import { LlmConnectionSettings } from './settings' import { getAvailableOpenaiModels } from './open-ai' import { modelCacheUpdated } from './registry' -const modelToConnectionCache = new Map() - -export interface ConnectionModel { - connection: LlmConnectionSettings - model: string -} - -const modelExclusionList = [ - /^dall-e/, - /embedding/, - /(^|-)tts(-|$)/, - /^whisper-/, -] - -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) { - if (modelExclusionList.some((regex) => regex.test(model))) { - continue - } - models.push({ connection, model }) - } - } - } - return models -} +export const modelToConnectionCache = new Map() export function getConnectionId(connection: LlmConnectionSettings) { return connection.type + connection.baseUrl diff --git a/src/main.ts b/src/main.ts index 74da1e8..e0dc7c4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,7 +59,7 @@ export default class LlmDocsPlugin extends Plugin implements ILlmDocsPlugin { editorCallback: async (editor, view) => { if (!view.file) return - const model = await new ModelPickerModal(this.app, this.settings.connections).openAndGetResult() + const model = await new ModelPickerModal(this.app, this).openAndGetResult() if (model) { await this.app.fileManager.processFrontMatter(view.file, (frontmatter) => { frontmatter.model = model diff --git a/src/settings-tab/index.ts b/src/settings-tab/index.ts index 9cbe73e..a005b47 100644 --- a/src/settings-tab/index.ts +++ b/src/settings-tab/index.ts @@ -7,13 +7,13 @@ import { ModelPickerModal } from './model-picker' import { FolderSuggest } from './folder-suggest' export class SettingsTab extends PluginSettingTab { - plugin: LlmDocsPlugin - unsubscribe = modelCacheUpdated.on(() => this.display()) - constructor(app: App, plugin: LlmDocsPlugin) { + constructor( + app: App, + private plugin: LlmDocsPlugin, + ) { super(app, plugin) - this.plugin = plugin } hide() { @@ -89,10 +89,7 @@ export class SettingsTab extends PluginSettingTab { .setDesc('The default LLM model variant to use for new LLM documents') .addButton((button) => { button.setButtonText('Select').onClick(async () => { - const model = await new ModelPickerModal( - this.app, - this.plugin.settings.connections, - ).openAndGetResult() + const model = await new ModelPickerModal(this.app, this.plugin).openAndGetResult() if (model) { await save(model) // force refresh of textbox in a way that is resilient to diff --git a/src/settings-tab/model-picker.ts b/src/settings-tab/model-picker.ts index 5a22512..14e8b2c 100644 --- a/src/settings-tab/model-picker.ts +++ b/src/settings-tab/model-picker.ts @@ -1,26 +1,20 @@ -import { App, SuggestModal } from 'obsidian' -import { LlmConnectionSettings } from '../settings' -import { - ConnectionModel, - getAllAvailableModelsAndUpdateCache, - getCachedConnectionModels, - getConnectionId, -} from '../connection-models' +import { App, getIcon, SuggestModal } from 'obsidian' +import { PluginSettings } from '../settings' +import { getAllAvailableModelsAndUpdateCache, getConnectionId, modelToConnectionCache } from '../connection-models' import { modelCacheUpdated } from '../registry' import { ValueEmitter } from '../utils' +import LlmDocsPlugin from '../main' -export class ModelPickerModal extends SuggestModal { - private models: ConnectionModel[] +export class ModelPickerModal extends SuggestModal { + private models: Model[] private onResult = new ValueEmitter() private unsubscribe = modelCacheUpdated.on(() => { this.refreshModels() - // a bit dangerous as we are invoking internals, but there's no other way to refresh the list once opened - ;(this as any).updateSuggestions() }) constructor( app: App, - private connections: LlmConnectionSettings[], + private plugin: LlmDocsPlugin, ) { super(app) this.refreshModels() @@ -39,38 +33,57 @@ export class ModelPickerModal extends SuggestModal { private refreshModels() { const collator = new Intl.Collator() - this.models = getCachedConnectionModels(this.connections).sort((a, b) => - collator.compare(sortKey(a), sortKey(b)), - ) + this.models = getCachedModels(this.plugin.settings).sort((a, b) => collator.compare(sortKey(a), sortKey(b))) + // a bit dangerous as we are invoking internals, but there's no other way to refresh the list once opened + ;(this as any).updateSuggestions() } private getEmptyStateText(): string { - if (!this.connections.length) { + if (!this.plugin.settings.connections.length) { return `No results. You need to add a connection first!` } return 'Loading...' } - getSuggestions(query: string): ConnectionModel[] { - return this.models.filter((model) => model.model.toLowerCase().includes(query.toLowerCase())) + getSuggestions(query: string): Model[] { + return this.models.filter((model) => model.name.toLowerCase().includes(query.toLowerCase())) } - renderSuggestion({ connection, model }: ConnectionModel, el: HTMLElement) { - el.createEl('div', { text: model, cls: 'llmdocs-suggest-item-title' }) + renderSuggestion(model: Model, el: HTMLElement) { + const pin = el.createEl('div', { cls: 'llmdocs-suggest-item-pin' }) + pin.onclick = async (evt: MouseEvent) => { + evt.stopPropagation() + await this.togglePinned(model) + } + if (model.isPinned) { + pin.addClass('pinned') + } + pin.append(getIcon('pin') as Node) + el.createEl('div', { text: model.name, cls: 'llmdocs-suggest-item-title' }) el.createEl('small', { - text: connection.baseUrl, + text: model.connectionUrl, cls: 'llmdocs-suggest-item-subtext', }) } - onChooseSuggestion(model: ConnectionModel, evt: MouseEvent | KeyboardEvent) { - this.onResult.emit(model.model) + async togglePinned(model: Model) { + if (model.isPinned) { + delete this.plugin.settings.pinnedModels[model.name] + } else { + this.plugin.settings.pinnedModels[model.name] = true + } + await this.plugin.saveSettings() + this.refreshModels() + } + + onChooseSuggestion(model: Model, evt: MouseEvent | KeyboardEvent) { + this.onResult.emit(model.name) } onOpen() { super.onOpen() - getAllAvailableModelsAndUpdateCache(this.connections).then() + getAllAvailableModelsAndUpdateCache(this.plugin.settings.connections).then() } onClose() { @@ -86,6 +99,51 @@ export class ModelPickerModal extends SuggestModal { } } -function sortKey(model: ConnectionModel): string { - return getConnectionId(model.connection) + '_' + model.model +function sortKey(model: Model): string { + return (model.isPinned ? '0' : '1') + (model.isDefault ? '0' : '1') + model.connectionIndex + '_' + model.name +} + +interface Model { + name: string + connectionUrl: string + connectionIndex: number + isDefault: boolean + isPinned: boolean +} + +const modelExclusionList = [/^dall-e/, /embedding/, /(^|-)tts(-|$)/, /^whisper-/] + +function getCachedModels(settings: PluginSettings): Model[] { + const defaultModel = settings.defaults.model + const pinnedModels = settings.pinnedModels + + const connectionUrls = new Map() + const connectionIndices = new Map() + settings.connections.forEach((connection, index) => { + const connectionId = getConnectionId(connection) + connectionUrls.set(connectionId, connection.baseUrl) + connectionIndices.set(connectionId, index) + }) + + const models: Model[] = [] + for (const [modelName, connectionId] of modelToConnectionCache) { + if (modelExclusionList.some((regex) => regex.test(modelName))) { + continue + } + + const connectionUrl = connectionUrls.get(connectionId) + const connectionIndex = connectionIndices.get(connectionId) + if (connectionUrl === undefined || connectionIndex === undefined) { + continue + } + + models.push({ + connectionUrl, + connectionIndex, + name: modelName, + isDefault: modelName === defaultModel, + isPinned: pinnedModels[modelName] ?? false, + }) + } + return models } diff --git a/src/settings.ts b/src/settings.ts index 943458d..0d24d2c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -2,6 +2,7 @@ export interface PluginSettings { docsDir: string connections: LlmConnectionSettings[] defaults: DefaultsSettings + pinnedModels: Record } export enum DocOpenMethods { @@ -27,6 +28,7 @@ export interface LlmConnectionSettings { export const defaultPluginSettings: PluginSettings = { docsDir: 'LLM', connections: [], + pinnedModels: {}, defaults: { model: 'gpt-4o', systemPrompt: '', diff --git a/styles.css b/styles.css index 9fa25c2..e9a12ff 100644 --- a/styles.css +++ b/styles.css @@ -74,3 +74,36 @@ If your plugin does not need CSS, delete this file. .llmdocs-suggest-item-subtext { color: var(--text-muted); } + +.llmdocs-suggest-item-pin { + float: right; + border-radius: 2.5em; + width: 2.5em; + height: 2.5em; + display: flex; + justify-content: center; + align-items: center; + visibility: hidden; + color: var(--icon-color); + opacity: var(--icon-opacity); + cursor: pointer; +} + +.is-selected .llmdocs-suggest-item-pin { + visibility: visible; +} + +.llmdocs-suggest-item-pin:hover { + opacity: var(--icon-opacity-hover); + color: var(--icon-color-hover); + background-color: var(--background-modifier-hover); +} + +.llmdocs-suggest-item-pin.pinned { + visibility: visible; + color: var(--text-accent); +} + +.llmdocs-suggest-item-pin.pinned:hover { + background-color: var(--background-primary); +}