model picker: enable pinning

This commit is contained in:
Shane Lamb 2025-06-23 20:24:46 +10:00
parent 20b9722ad2
commit a1d59b59ca
6 changed files with 127 additions and 65 deletions

View file

@ -2,35 +2,7 @@ import { LlmConnectionSettings } from './settings'
import { getAvailableOpenaiModels } from './open-ai'
import { modelCacheUpdated } from './registry'
const modelToConnectionCache = new Map<string, string>()
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<string, string>()
export function getConnectionId(connection: LlmConnectionSettings) {
return connection.type + connection.baseUrl

View file

@ -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

View file

@ -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

View file

@ -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<ConnectionModel> {
private models: ConnectionModel[]
export class ModelPickerModal extends SuggestModal<Model> {
private models: Model[]
private onResult = new ValueEmitter<string | null>()
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<ConnectionModel> {
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<ConnectionModel> {
}
}
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<string, string>()
const connectionIndices = new Map<string, number>()
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
}

View file

@ -2,6 +2,7 @@ export interface PluginSettings {
docsDir: string
connections: LlmConnectionSettings[]
defaults: DefaultsSettings
pinnedModels: Record<string, boolean>
}
export enum DocOpenMethods {
@ -27,6 +28,7 @@ export interface LlmConnectionSettings {
export const defaultPluginSettings: PluginSettings = {
docsDir: 'LLM',
connections: [],
pinnedModels: {},
defaults: {
model: 'gpt-4o',
systemPrompt: '',

View file

@ -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);
}