feat(stage-ui,server): use optimistic request for provider catalog (#951)
--------- Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
@@ -10,6 +10,7 @@ export const SystemProviderConfigSchema = createSelectSchema(schema.systemProvid
|
||||
export const InsertSystemProviderConfigSchema = createInsertSchema(schema.systemProviderConfigs)
|
||||
|
||||
export const CreateProviderConfigSchema = object({
|
||||
id: optional(string()),
|
||||
definitionId: string(),
|
||||
name: string(),
|
||||
config: optional(record(string(), string())),
|
||||
|
||||
@@ -4,8 +4,10 @@ export * from './llm-marker-parser'
|
||||
export * from './markdown'
|
||||
export * from './queues'
|
||||
export * from './use-analytics'
|
||||
export * from './use-async-state'
|
||||
export * from './use-build-info'
|
||||
export * from './use-chat-session/summary'
|
||||
export * from './use-optimistic'
|
||||
export * from './use-scroll-to-hash'
|
||||
export * from './use-versioned-local-storage'
|
||||
export * from './whisper'
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useOptimisticMutation } from './use-optimistic'
|
||||
|
||||
describe('useOptimistic', () => {
|
||||
it('should perform a successful optimistic update', async () => {
|
||||
const state = ref('initial')
|
||||
const actionResult = 'real-data'
|
||||
|
||||
const apply = vi.fn(() => {
|
||||
const old = state.value
|
||||
state.value = 'optimistic'
|
||||
return () => {
|
||||
state.value = old
|
||||
}
|
||||
})
|
||||
|
||||
const action = vi.fn(async () => {
|
||||
return actionResult
|
||||
})
|
||||
|
||||
const onSuccess = vi.fn((result: string) => {
|
||||
state.value = `final-${result}`
|
||||
return state.value
|
||||
})
|
||||
|
||||
const { state: resultState, isLoading } = useOptimisticMutation({
|
||||
apply,
|
||||
action,
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
// Immediate check
|
||||
expect(state.value).toBe('optimistic')
|
||||
expect(apply).toHaveBeenCalled()
|
||||
|
||||
// Wait for action to complete
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(action).toHaveBeenCalled()
|
||||
expect(onSuccess).toHaveBeenCalledWith(actionResult)
|
||||
expect(state.value).toBe('final-real-data')
|
||||
expect(resultState.value).toBe('final-real-data')
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should rollback on action failure', async () => {
|
||||
const state = ref('initial')
|
||||
const error = new Error('action failed')
|
||||
|
||||
const rollback = vi.fn(() => {
|
||||
state.value = 'initial'
|
||||
})
|
||||
|
||||
const apply = vi.fn(() => {
|
||||
state.value = 'optimistic'
|
||||
return rollback
|
||||
})
|
||||
|
||||
const action = vi.fn(async () => {
|
||||
throw error
|
||||
})
|
||||
|
||||
const { error: errorState, isLoading } = useOptimisticMutation({
|
||||
apply,
|
||||
action,
|
||||
})
|
||||
|
||||
expect(state.value).toBe('optimistic')
|
||||
|
||||
// Wait for failure
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(rollback).toHaveBeenCalled()
|
||||
expect(state.value).toBe('initial')
|
||||
expect(errorState.value).toBe(error)
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle async apply and rollback', async () => {
|
||||
const state = ref('initial')
|
||||
|
||||
const apply = async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
state.value = 'optimistic'
|
||||
return async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
state.value = 'initial'
|
||||
}
|
||||
}
|
||||
|
||||
const action = async () => {
|
||||
throw new Error('fail')
|
||||
}
|
||||
|
||||
const { execute } = useOptimisticMutation({
|
||||
apply,
|
||||
action,
|
||||
})
|
||||
|
||||
await execute()
|
||||
|
||||
expect(state.value).toBe('initial')
|
||||
})
|
||||
|
||||
it('should not throw if apply returns non-function', async () => {
|
||||
const action = vi.fn(async () => {
|
||||
throw new Error('fail')
|
||||
})
|
||||
|
||||
const { execute, error } = useOptimisticMutation({
|
||||
// @ts-expect-error - testing invalid return
|
||||
apply: () => null,
|
||||
action,
|
||||
})
|
||||
|
||||
await execute()
|
||||
expect(error.value).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useAsyncState } from './use-async-state'
|
||||
|
||||
export interface UseOptimisticMutationOptions<T, R, E = unknown> {
|
||||
/**
|
||||
* The optimistic update logic.
|
||||
* Should return a rollback function.
|
||||
*/
|
||||
apply: () => Promise<(() => Promise<void> | void)> | (() => Promise<void> | void)
|
||||
/**
|
||||
* The actual async task (e.g., API call).
|
||||
*/
|
||||
action: () => Promise<T>
|
||||
/**
|
||||
* Optional callback after successful action to refine state (e.g., replacing temp IDs).
|
||||
*/
|
||||
onSuccess?: (result: T) => Promise<R> | R
|
||||
/**
|
||||
* Optional callback on error. Rollback is handled automatically.
|
||||
*/
|
||||
onError?: (error?: E | null) => void | Promise<void>
|
||||
|
||||
/**
|
||||
* Whether to execute the action lazily.
|
||||
*/
|
||||
lazy?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper for performing optimistic mutations with automatic rollback.
|
||||
* Integrates with useAsyncState for loading/error tracking.
|
||||
* TODO: use https://pinia-colada.esm.dev/guide/mutations.html instead.
|
||||
*/
|
||||
export function useOptimisticMutation<T, R = T, E = unknown>(options: UseOptimisticMutationOptions<T, R, E>) {
|
||||
const { apply, action, onSuccess, onError, lazy = false } = options
|
||||
|
||||
return useAsyncState(async () => {
|
||||
const rollback = await apply()
|
||||
|
||||
try {
|
||||
const result = await action()
|
||||
if (onSuccess) {
|
||||
return await onSuccess(result)
|
||||
}
|
||||
return result as unknown as R
|
||||
}
|
||||
catch (err) {
|
||||
if (typeof rollback === 'function') {
|
||||
await rollback()
|
||||
}
|
||||
if (onError) {
|
||||
await onError(err as E)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}, { immediate: !lazy })
|
||||
}
|
||||
@@ -1,9 +1,33 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { providerOpenAICompatible } from '../libs/providers/providers/openai-compatible'
|
||||
import { useProviderCatalogStore } from './provider-catalog'
|
||||
|
||||
vi.mock('../database/repos/providers.repo', () => ({
|
||||
providersRepo: {
|
||||
getAll: vi.fn(async () => ({})),
|
||||
saveAll: vi.fn(async () => {}),
|
||||
upsert: vi.fn(async () => {}),
|
||||
remove: vi.fn(async () => {}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../composables/api', () => ({
|
||||
client: {
|
||||
api: {
|
||||
providers: {
|
||||
'$get': vi.fn(async () => ({ ok: true, json: async () => [] })),
|
||||
'$post': vi.fn(async () => ({ ok: true, json: async () => ({ id: 'real-id', definitionId: 'openai-compatible', name: 'OpenAI Compatible', config: {}, validated: false, validationBypassed: false }) })),
|
||||
':id': {
|
||||
$delete: vi.fn(async () => ({ ok: true })),
|
||||
$patch: vi.fn(async () => ({ ok: true, json: async () => ({}) })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('store provider-catalog', () => {
|
||||
beforeEach(() => {
|
||||
// creates a fresh pinia and makes it active
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ProviderCatalogProvider } from '../database/repos/providers.repo'
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { client } from '../composables/api'
|
||||
import { useAsyncState } from '../composables/use-async-state'
|
||||
import { useOptimisticMutation } from '../composables/use-optimistic'
|
||||
import { providersRepo } from '../database/repos/providers.repo'
|
||||
import { getDefinedProvider, listProviders } from '../libs/providers/providers'
|
||||
|
||||
@@ -43,78 +45,135 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
|
||||
}
|
||||
|
||||
async function addProvider(definitionId: string, initialConfig: Record<string, any> = {}) {
|
||||
if (!getDefinedProvider(definitionId)) {
|
||||
const definition = getDefinedProvider(definitionId)
|
||||
if (!definition) {
|
||||
throw new Error(`Provider definition with id "${definitionId}" not found.`)
|
||||
}
|
||||
|
||||
return useAsyncState(async () => {
|
||||
const res = await client.api.providers.$post({
|
||||
json: {
|
||||
definitionId,
|
||||
name: getDefinedProvider(definitionId)!.name,
|
||||
config: initialConfig,
|
||||
validated: false,
|
||||
validationBypassed: false,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to add provider')
|
||||
}
|
||||
const item = await res.json()
|
||||
const id = nanoid()
|
||||
const provider: ProviderCatalogProvider = {
|
||||
id,
|
||||
definitionId,
|
||||
name: definition.name,
|
||||
config: initialConfig,
|
||||
validated: false,
|
||||
validationBypassed: false,
|
||||
}
|
||||
|
||||
const provider: ProviderCatalogProvider = {
|
||||
id: item.id,
|
||||
definitionId: item.definitionId,
|
||||
name: item.name,
|
||||
config: item.config as Record<string, any>,
|
||||
validated: item.validated,
|
||||
validationBypassed: item.validationBypassed,
|
||||
}
|
||||
configs.value[item.id] = provider
|
||||
await providersRepo.upsert(provider)
|
||||
return item
|
||||
}, { immediate: true })
|
||||
return useOptimisticMutation<any, any>({
|
||||
apply: async () => {
|
||||
configs.value[id] = provider
|
||||
await providersRepo.upsert(provider)
|
||||
return async () => {
|
||||
delete configs.value[id]
|
||||
await providersRepo.remove(id)
|
||||
}
|
||||
},
|
||||
action: async () => {
|
||||
const res = await client.api.providers.$post({
|
||||
json: {
|
||||
id,
|
||||
definitionId,
|
||||
name: provider.name,
|
||||
config: provider.config,
|
||||
validated: provider.validated,
|
||||
validationBypassed: provider.validationBypassed,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to add provider')
|
||||
}
|
||||
return await res.json()
|
||||
},
|
||||
onSuccess: async (item: any) => {
|
||||
const finalProvider: ProviderCatalogProvider = {
|
||||
id: item.id,
|
||||
definitionId: item.definitionId,
|
||||
name: item.name,
|
||||
config: item.config as Record<string, any>,
|
||||
validated: item.validated,
|
||||
validationBypassed: item.validationBypassed,
|
||||
}
|
||||
|
||||
configs.value[item.id] = finalProvider
|
||||
await providersRepo.upsert(finalProvider)
|
||||
return item
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function removeProvider(providerId: string) {
|
||||
return useAsyncState(async () => {
|
||||
const res = await client.api.providers[':id'].$delete({
|
||||
param: { id: providerId },
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to remove provider')
|
||||
}
|
||||
delete configs.value[providerId]
|
||||
await providersRepo.remove(providerId)
|
||||
}, { immediate: true })
|
||||
}
|
||||
|
||||
async function commitProviderConfig(providerId: string, newConfig: Record<string, any>, options: { validated: boolean, validationBypassed: boolean }) {
|
||||
if (!configs.value[providerId]) {
|
||||
const original = configs.value[providerId]
|
||||
if (!original) {
|
||||
return
|
||||
}
|
||||
|
||||
return useAsyncState(async () => {
|
||||
const res = await client.api.providers[':id'].$patch({
|
||||
param: { id: providerId },
|
||||
// @ts-expect-error hono client typing misses json option for this route
|
||||
json: {
|
||||
config: newConfig,
|
||||
validated: options.validated,
|
||||
validationBypassed: options.validationBypassed,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to update provider config')
|
||||
}
|
||||
const item = await res.json()
|
||||
return useOptimisticMutation<void, void>({
|
||||
apply: async () => {
|
||||
delete configs.value[providerId]
|
||||
await providersRepo.remove(providerId)
|
||||
return async () => {
|
||||
configs.value[providerId] = original
|
||||
await providersRepo.upsert(original)
|
||||
}
|
||||
},
|
||||
action: async () => {
|
||||
const res = await client.api.providers[':id'].$delete({
|
||||
param: { id: providerId },
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to remove provider')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const provider = configs.value[providerId]
|
||||
provider.config = { ...item.config as Record<string, any> }
|
||||
provider.validated = item.validated
|
||||
provider.validationBypassed = item.validationBypassed
|
||||
await providersRepo.upsert(provider)
|
||||
}, { immediate: true })
|
||||
async function commitProviderConfig(providerId: string, newConfig: Record<string, any>, options: { validated: boolean, validationBypassed: boolean }) {
|
||||
const provider = configs.value[providerId]
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalConfig = { ...provider.config }
|
||||
const originalValidated = provider.validated
|
||||
const originalValidationBypassed = provider.validationBypassed
|
||||
|
||||
return useOptimisticMutation<any, void>({
|
||||
apply: async () => {
|
||||
provider.config = { ...newConfig }
|
||||
provider.validated = options.validated
|
||||
provider.validationBypassed = options.validationBypassed
|
||||
await providersRepo.upsert(provider)
|
||||
return async () => {
|
||||
provider.config = originalConfig
|
||||
provider.validated = originalValidated
|
||||
provider.validationBypassed = originalValidationBypassed
|
||||
await providersRepo.upsert(provider)
|
||||
}
|
||||
},
|
||||
action: async () => {
|
||||
const res = await client.api.providers[':id'].$patch({
|
||||
param: { id: providerId },
|
||||
// @ts-expect-error hono client typing misses json option for this route
|
||||
json: {
|
||||
config: newConfig,
|
||||
validated: options.validated,
|
||||
validationBypassed: options.validationBypassed,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to update provider config')
|
||||
}
|
||||
return await res.json()
|
||||
},
|
||||
onSuccess: async (item: any) => {
|
||||
// Sync with server response just in case
|
||||
provider.config = { ...item.config as Record<string, any> }
|
||||
provider.validated = item.validated
|
||||
provider.validationBypassed = item.validationBypassed
|
||||
await providersRepo.upsert(provider)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -134,6 +134,7 @@ export const CharacterWithRelationsSchema = object({
|
||||
|
||||
export const CreateCharacterSchema = object({
|
||||
character: object({
|
||||
id: optional(string()),
|
||||
version: string(),
|
||||
coverUrl: string(),
|
||||
characterId: string(),
|
||||
|
||||
Reference in New Issue
Block a user