diff --git a/apps/server/src/api/providers.schema.ts b/apps/server/src/api/providers.schema.ts index 34629d097..7cc9deaf7 100644 --- a/apps/server/src/api/providers.schema.ts +++ b/apps/server/src/api/providers.schema.ts @@ -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())), diff --git a/packages/stage-ui/src/composables/index.ts b/packages/stage-ui/src/composables/index.ts index 5d59e99c3..f1058d681 100644 --- a/packages/stage-ui/src/composables/index.ts +++ b/packages/stage-ui/src/composables/index.ts @@ -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' diff --git a/packages/stage-ui/src/composables/use-optimistic.test.ts b/packages/stage-ui/src/composables/use-optimistic.test.ts new file mode 100644 index 000000000..e89c325e6 --- /dev/null +++ b/packages/stage-ui/src/composables/use-optimistic.test.ts @@ -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() + }) +}) diff --git a/packages/stage-ui/src/composables/use-optimistic.ts b/packages/stage-ui/src/composables/use-optimistic.ts new file mode 100644 index 000000000..2dce808c8 --- /dev/null +++ b/packages/stage-ui/src/composables/use-optimistic.ts @@ -0,0 +1,56 @@ +import { useAsyncState } from './use-async-state' + +export interface UseOptimisticMutationOptions { + /** + * The optimistic update logic. + * Should return a rollback function. + */ + apply: () => Promise<(() => Promise | void)> | (() => Promise | void) + /** + * The actual async task (e.g., API call). + */ + action: () => Promise + /** + * Optional callback after successful action to refine state (e.g., replacing temp IDs). + */ + onSuccess?: (result: T) => Promise | R + /** + * Optional callback on error. Rollback is handled automatically. + */ + onError?: (error?: E | null) => void | Promise + + /** + * 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(options: UseOptimisticMutationOptions) { + 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 }) +} diff --git a/packages/stage-ui/src/stores/provider-catalog.test.ts b/packages/stage-ui/src/stores/provider-catalog.test.ts index cea17ef3e..03a47acf8 100644 --- a/packages/stage-ui/src/stores/provider-catalog.test.ts +++ b/packages/stage-ui/src/stores/provider-catalog.test.ts @@ -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 diff --git a/packages/stage-ui/src/stores/provider-catalog.ts b/packages/stage-ui/src/stores/provider-catalog.ts index 587abb257..dfdfb8122 100644 --- a/packages/stage-ui/src/stores/provider-catalog.ts +++ b/packages/stage-ui/src/stores/provider-catalog.ts @@ -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 = {}) { - 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, - validated: item.validated, - validationBypassed: item.validationBypassed, - } - configs.value[item.id] = provider - await providersRepo.upsert(provider) - return item - }, { immediate: true }) + return useOptimisticMutation({ + 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, + 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, 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({ + 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 } - provider.validated = item.validated - provider.validationBypassed = item.validationBypassed - await providersRepo.upsert(provider) - }, { immediate: true }) + async function commitProviderConfig(providerId: string, newConfig: Record, 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({ + 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 } + provider.validated = item.validated + provider.validationBypassed = item.validationBypassed + await providersRepo.upsert(provider) + }, + }) } return { diff --git a/packages/stage-ui/src/types/character.ts b/packages/stage-ui/src/types/character.ts index 131926301..004e3e855 100644 --- a/packages/stage-ui/src/types/character.ts +++ b/packages/stage-ui/src/types/character.ts @@ -134,6 +134,7 @@ export const CharacterWithRelationsSchema = object({ export const CreateCharacterSchema = object({ character: object({ + id: optional(string()), version: string(), coverUrl: string(), characterId: string(),