feat(stage-ui,server): use optimistic request for provider catalog (#951)

---------

Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
RainbowBird
2026-01-17 00:17:26 +08:00
committed by GitHub
co-authored by Neko
parent 815ce36bce
commit 4292535d10
7 changed files with 328 additions and 62 deletions
+1
View File
@@ -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
+120 -61
View File
@@ -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 {
+1
View File
@@ -134,6 +134,7 @@ export const CharacterWithRelationsSchema = object({
export const CreateCharacterSchema = object({
character: object({
id: optional(string()),
version: string(),
coverUrl: string(),
characterId: string(),