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.
This commit is contained in:
Shane Lamb 2025-03-27 23:20:53 +10:00
parent ab77edc24e
commit 9505cf1bfb
8 changed files with 107 additions and 53 deletions

View file

@ -1,17 +1,44 @@
import { LlmConnectionSettings } from './settings'
import { getAvailableOpenaiModels } from './open-ai'
import { modelCacheUpdated } from './registry'
const modelToConnectionIdCache = new Map<string, string>()
const modelToConnectionCache = new Map<string, string>()
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)

View file

@ -22,7 +22,7 @@ export interface OpenaiContent {
}
}
export async function getAvailableOpenaiModels(settings: LlmConnectionSettings): Promise<string[] | undefined> {
export async function getAvailableOpenaiModels(settings: LlmConnectionSettings): Promise<string[]> {
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 {

View file

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

View file

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

View file

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

View file

@ -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<ConnectionModel> {
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
}

View file

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

View file

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