pair programming fix model refresh state

This commit is contained in:
Mathew Pareles 2025-01-15 05:58:31 -08:00
parent d4fc59034a
commit 8cfe494632
5 changed files with 60 additions and 51 deletions

View file

@ -25,11 +25,20 @@ type RefreshableState = ({
state: 'finished',
timeoutId: null,
} | {
state: 'finished_invisible',
state: 'error',
timeoutId: null,
})
/*
user click -> error -> fire(error)
\> success -> fire(success)
finally: keep polling
poll -> do not fire
*/
export type RefreshModelStateOfProvider = Record<RefreshableProviderName, RefreshableState>
@ -41,6 +50,8 @@ const refreshBasedOn: { [k in RefreshableProviderName]: (keyof SettingsOfProvide
const REFRESH_INTERVAL = 5_000
// const COOLDOWN_TIMEOUT = 300
const autoOptions = { enableProviderOnSuccess: true, doNotFire: true }
// element-wise equals
function eq<T>(a: T[], b: T[]): boolean {
if (a.length !== b.length) return false
@ -51,7 +62,7 @@ function eq<T>(a: T[], b: T[]): boolean {
}
export interface IRefreshModelService {
readonly _serviceBrand: undefined;
refreshModels: (providerName: RefreshableProviderName) => Promise<void>;
startRefreshingModels: (providerName: RefreshableProviderName, options: { enableProviderOnSuccess: boolean, doNotFire: boolean }) => void;
onDidChangeState: Event<RefreshableProviderName>;
state: RefreshModelStateOfProvider;
}
@ -75,7 +86,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
const disposables: Set<IDisposable> = new Set()
const initializePollingAndOnChange = () => {
const initializeAutoPollingAndOnChange = () => {
this._clearAllTimeouts()
disposables.forEach(d => d.dispose())
disposables.clear()
@ -84,14 +95,14 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
for (const providerName of refreshableProviderNames) {
const { _enabled: enabled } = this.voidSettingsService.state.settingsOfProvider[providerName]
this.refreshModels(providerName, !enabled, { isPolling: true, isInvisible: true })
// const { _enabled: enabled } = this.voidSettingsService.state.settingsOfProvider[providerName]
this.startRefreshingModels(providerName, autoOptions)
// every time providerName.enabled changes, refresh models too, like a useEffect
let relevantVals = () => refreshBasedOn[providerName].map(settingName => this.voidSettingsService.state.settingsOfProvider[providerName][settingName])
let relevantVals = () => refreshBasedOn[providerName].map(settingName => voidSettingsService.state.settingsOfProvider[providerName][settingName])
let prevVals = relevantVals() // each iteration of a for loop has its own context and vars, so this is ok
disposables.add(
this.voidSettingsService.onDidChangeState(() => { // we might want to debounce this
voidSettingsService.onDidChangeState(() => { // we might want to debounce this
const newVals = relevantVals()
if (!eq(prevVals, newVals)) {
@ -101,7 +112,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
// if it was just enabled, or there was a change and it wasn't to the enabled state, refresh
if ((enabled && !prevEnabled) || (!enabled && !prevEnabled)) {
// if user just clicked enable, refresh
this.refreshModels(providerName, !enabled, { isPolling: false, isInvisible: true })
this.startRefreshingModels(providerName, autoOptions)
}
else {
// else if user just clicked disable, don't refresh
@ -119,9 +130,9 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
// on mount (when get init settings state), and if a relevant feature flag changes, start refreshing models
voidSettingsService.waitForInitState.then(() => {
initializePollingAndOnChange()
initializeAutoPollingAndOnChange()
this._register(
voidSettingsService.onDidChangeState((type) => { if (typeof type === 'object' && type[1] === 'autoRefreshModels') initializePollingAndOnChange() })
voidSettingsService.onDidChangeState((type) => { if (typeof type === 'object' && type[1] === 'autoRefreshModels') initializeAutoPollingAndOnChange() })
)
})
@ -129,22 +140,23 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
state: RefreshModelStateOfProvider = {
ollama: { state: 'init', timeoutId: null },
// openAICompatible: { state: 'init', timeoutId: null },
}
// start listening for models (and don't stop until success)
async refreshModels(providerName: RefreshableProviderName, enableProviderOnSuccess?: boolean, options?: { isPolling?: boolean, isInvisible?: boolean }) {
const { isPolling, isInvisible } = options ?? {}
// console.log(`refreshModels, isInvisible ${isInvisible} isPolling ${isPolling}`)
startRefreshingModels: IRefreshModelService['startRefreshingModels'] = (providerName, options) => {
this._clearProviderTimeout(providerName)
// start loading models
if (!isInvisible) this._setRefreshState(providerName, 'refreshing')
this._setRefreshState(providerName, 'refreshing', options)
const autoPoll = () => {
if (this.voidSettingsService.state.globalSettings.autoRefreshModels) {
// resume auto-polling
const timeoutId = setTimeout(() => this.startRefreshingModels(providerName, autoOptions), REFRESH_INTERVAL)
this._setTimeoutId(providerName, timeoutId)
}
}
const listFn = providerName === 'ollama' ? this.llmMessageService.ollamaList
: providerName === 'openAICompatible' ? this.llmMessageService.openAICompatibleList
: () => { }
@ -160,34 +172,21 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
else if (providerName === 'openAICompatible') return (model as OpenaiCompatibleModelResponse).id;
else throw new Error('refreshMode fn: unknown provider', providerName);
}),
{ enableProviderOnSuccess, isPolling, isInvisible }
{ enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire }
)
// update state
if (enableProviderOnSuccess) {
this.voidSettingsService.setSettingOfProvider(providerName, '_enabled', true)
}
if (!isInvisible) {
this._setRefreshState(providerName, 'finished')
} else if (isInvisible) {
this._setRefreshState(providerName, 'finished_invisible')
}
if (options.enableProviderOnSuccess) this.voidSettingsService.setSettingOfProvider(providerName, '_enabled', true)
this._setRefreshState(providerName, 'finished', options)
autoPoll()
},
onError: ({ error }) => {
console.log('retrying list models:', providerName, error)
this._setRefreshState(providerName, 'error', options)
autoPoll()
}
})
// check if we should poll
// if it was originally called as `isPolling` and if the `autoRefreshModels` flag is enabled
if (isPolling && this.voidSettingsService.state.globalSettings['autoRefreshModels']) {
const timeoutId = setTimeout(() => this.refreshModels(providerName, enableProviderOnSuccess, options), REFRESH_INTERVAL)
this._setTimeoutId(providerName, timeoutId)
}
}
_clearAllTimeouts() {
@ -208,7 +207,8 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
this.state[providerName].timeoutId = timeoutId
}
private _setRefreshState(providerName: RefreshableProviderName, state: RefreshableState['state']) {
private _setRefreshState(providerName: RefreshableProviderName, state: RefreshableState['state'], options?: { doNotFire: boolean }) {
if (options?.doNotFire) return
this.state[providerName].state = state
this._onDidChangeState.fire(providerName)
}

View file

@ -57,7 +57,7 @@ export interface IVoidSettingsService {
setModelSelectionOfFeature: SetModelSelectionOfFeatureFn;
setGlobalSetting: SetGlobalSettingFn;
setAutodetectedModels(providerName: ProviderName, modelNames: string[], logging: { enableProviderOnSuccess?: boolean, isPolling?: boolean, isInvisible?: boolean }): void;
setAutodetectedModels(providerName: ProviderName, modelNames: string[], logging: object): void;
toggleModelHidden(providerName: ProviderName, modelName: string): void;
addModel(providerName: ProviderName, modelName: string): void;
deleteModel(providerName: ProviderName, modelName: string): boolean;
@ -223,7 +223,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
setAutodetectedModels(providerName: ProviderName, newDefaultModelNames: string[], logging: { enableProviderOnSuccess?: boolean, isPolling?: boolean, isInvisible?: boolean }) {
setAutodetectedModels(providerName: ProviderName, newDefaultModelNames: string[], logging: object) {
const { models } = this.state.settingsOfProvider[providerName]

View file

@ -9,7 +9,7 @@ import { errorDetails } from '../../../../../../../platform/void/common/llmMessa
export const ErrorDisplay = ({
message,
message:message_,
fullError,
onDismiss,
showDismiss,
@ -23,6 +23,8 @@ export const ErrorDisplay = ({
const details = errorDetails(fullError)
const message = message_ === 'TypeError: fetch failed' ? 'TypeError: fetch failed. This likely means you specified the wrong endpoint in Void Settings.' : message_
return (
<div className={`rounded-lg border border-red-200 bg-red-50 p-4 overflow-auto`}>

View file

@ -716,7 +716,7 @@ export const SidebarChat = () => {
{/* text input */}
<VoidInputBox2
className='min-h-[81px]'
className='min-h-[81px] p-1'
placeholder={`${keybindingString} to select. Enter instructions...`}
onChangeText={useCallback((newStr: string) => { setInstructionsAreEmpty(!newStr) }, [setInstructionsAreEmpty])}
onKeyDown={(e) => {

View file

@ -38,32 +38,39 @@ const RefreshModelButton = ({ providerName }: { providerName: RefreshableProvide
const refreshModelService = accessor.get('IRefreshModelService')
const metricsService = accessor.get('IMetricsService')
const [justFinished, setJustFinished] = useState(false)
const [justFinished, setJustFinished] = useState<null | 'finished' | 'error'>(null)
useRefreshModelListener(
useCallback((providerName2, refreshModelState) => {
if (providerName2 !== providerName) return
const { state } = refreshModelState[providerName]
if (state !== 'finished') return
if (!(state === 'finished' || state === 'error')) return
// now we know we just entered 'finished' state for this providerName
setJustFinished(true)
const tid = setTimeout(() => { setJustFinished(false) }, 2000)
setJustFinished(state)
const tid = setTimeout(() => { setJustFinished(null) }, 2000)
return () => clearTimeout(tid)
}, [providerName])
)
const { state } = refreshModelState[providerName]
const isRefreshing = state === 'refreshing'
const { title: providerTitle } = displayInfoOfProviderName(providerName)
return <SubtleButton
onClick={() => {
refreshModelService.refreshModels(providerName)
refreshModelService.startRefreshingModels(providerName, { enableProviderOnSuccess: false, doNotFire: false })
metricsService.capture('Click', { providerName, action: 'Refresh Models' })
}}
text={justFinished ? `${providerTitle} Models are up-to-date!` : `Manually refresh models list for ${providerTitle}.`}
icon={isRefreshing ? <Loader2 className='size-3 animate-spin' /> : (justFinished ? <Check className='stroke-green-500 size-3' /> : <RefreshCw className='size-3' />)}
disabled={isRefreshing || justFinished}
text={justFinished === 'finished' ? `${providerTitle} Models are up-to-date!`
: justFinished === 'error' ? `${providerTitle} not found!`
: `Manually refresh ${providerTitle} models.`
}
icon={justFinished === 'finished' ? <Check className='stroke-green-500 size-3' />
: justFinished === 'error' ? <X className='stroke-red-500 size-3' />
: state === 'refreshing' ? <Loader2 className='size-3 animate-spin' />
: <RefreshCw className='size-3' />
}
disabled={state === 'refreshing' || justFinished !== null}
/>
}