From b4cc416472c7bd2b9752be2a7f25d7dd6205b933 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 21 Jan 2026 22:56:43 +0800 Subject: [PATCH] feat(stage-ui): use local first request (#977) --- .../src/composables/use-local-first.ts | 59 ++++ .../src/composables/use-optimistic.ts | 24 +- packages/stage-ui/src/stores/auth.ts | 2 + packages/stage-ui/src/stores/characters.ts | 327 +++++++++++++----- .../stage-ui/src/stores/provider-catalog.ts | 96 +++-- 5 files changed, 361 insertions(+), 147 deletions(-) create mode 100644 packages/stage-ui/src/composables/use-local-first.ts diff --git a/packages/stage-ui/src/composables/use-local-first.ts b/packages/stage-ui/src/composables/use-local-first.ts new file mode 100644 index 000000000..785c52e68 --- /dev/null +++ b/packages/stage-ui/src/composables/use-local-first.ts @@ -0,0 +1,59 @@ +import { getActivePinia } from 'pinia' +import { ref } from 'vue' + +import { useAuthStore } from '../stores/auth' + +async function canUseRemote(allowRemote?: () => boolean | Promise) { + if (allowRemote) + return await allowRemote() + if (!getActivePinia()) + return true + return useAuthStore().isAuthenticated +} + +export interface UseLocalFirstRequestOptions { + local: () => Promise | T + remote: () => Promise + allowRemote?: () => boolean | Promise + lazy?: boolean +} + +export function useLocalFirstRequest(options: UseLocalFirstRequestOptions) { + const { local, remote, allowRemote, lazy = false } = options + + const state = ref() + const isLoading = ref(false) + const error = ref(null) + + const execute = async () => { + isLoading.value = true + error.value = null + try { + state.value = await local() + if (await canUseRemote(allowRemote)) { + try { + state.value = await remote() + } + catch (err) { + error.value = err + } + } + } + catch (err) { + error.value = err + } + finally { + isLoading.value = false + } + } + + if (!lazy) + execute() + + return { + state, + isLoading, + error, + execute, + } +} diff --git a/packages/stage-ui/src/composables/use-optimistic.ts b/packages/stage-ui/src/composables/use-optimistic.ts index 2dce808c8..644bb98d4 100644 --- a/packages/stage-ui/src/composables/use-optimistic.ts +++ b/packages/stage-ui/src/composables/use-optimistic.ts @@ -18,6 +18,14 @@ export interface UseOptimisticMutationOptions { * Optional callback on error. Rollback is handled automatically. */ onError?: (error?: E | null) => void | Promise + /** + * Skip the action when this returns true. + */ + skipActionIf?: () => boolean | Promise + /** + * Decide whether to rollback after an error. + */ + shouldRollback?: (error: E) => boolean | Promise /** * Whether to execute the action lazily. @@ -31,10 +39,21 @@ export interface UseOptimisticMutationOptions { * TODO: use https://pinia-colada.esm.dev/guide/mutations.html instead. */ export function useOptimisticMutation(options: UseOptimisticMutationOptions) { - const { apply, action, onSuccess, onError, lazy = false } = options + const { + apply, + action, + onSuccess, + onError, + skipActionIf, + shouldRollback, + lazy = false, + } = options return useAsyncState(async () => { const rollback = await apply() + if (skipActionIf && await skipActionIf()) { + return undefined as R + } try { const result = await action() @@ -44,7 +63,8 @@ export function useOptimisticMutation(options: UseOptimis return result as unknown as R } catch (err) { - if (typeof rollback === 'function') { + const allowRollback = shouldRollback ? await shouldRollback(err as E) : true + if (allowRollback && typeof rollback === 'function') { await rollback() } if (onError) { diff --git a/packages/stage-ui/src/stores/auth.ts b/packages/stage-ui/src/stores/auth.ts index a0c0e9995..b34a6df5a 100644 --- a/packages/stage-ui/src/stores/auth.ts +++ b/packages/stage-ui/src/stores/auth.ts @@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => { const user = ref() const session = ref() const isAuthenticated = computed(() => !!user.value && !!session.value) + const userId = computed(() => user.value?.id ?? 'local') const isLoginOpen = ref(false) @@ -26,6 +27,7 @@ export const useAuthStore = defineStore('auth', () => { return { user, + userId, session, isAuthenticated, isLoginOpen, diff --git a/packages/stage-ui/src/stores/characters.ts b/packages/stage-ui/src/stores/characters.ts index e8fec8b7a..74e11b495 100644 --- a/packages/stage-ui/src/stores/characters.ts +++ b/packages/stage-ui/src/stores/characters.ts @@ -1,16 +1,80 @@ import type { Character, CreateCharacterPayload, UpdateCharacterPayload } from '../types/character' +import { nanoid } from 'nanoid' import { defineStore } from 'pinia' import { parse } from 'valibot' import { ref } from 'vue' import { client } from '../composables/api' -import { useAsyncState } from '../composables/use-async-state' +import { useLocalFirstRequest } from '../composables/use-local-first' import { charactersRepo } from '../database/repos/characters.repo' import { CharacterWithRelationsSchema } from '../types/character' +import { useAuthStore } from './auth' + +function buildLocalCharacter(userId: string, payload: CreateCharacterPayload) { + const id = payload.character.id ?? nanoid() + const now = new Date() + + return parse(CharacterWithRelationsSchema, { + id, + version: payload.character.version, + coverUrl: payload.character.coverUrl, + avatarUrl: undefined, + characterAvatarUrl: undefined, + coverBackgroundUrl: undefined, + creatorRole: undefined, + priceCredit: '0', + likesCount: 0, + bookmarksCount: 0, + interactionsCount: 0, + forksCount: 0, + creatorId: userId, + ownerId: userId, + characterId: payload.character.characterId, + createdAt: now, + updatedAt: now, + deletedAt: undefined, + capabilities: payload.capabilities?.map(capability => ({ + id: nanoid(), + characterId: id, + type: capability.type, + config: capability.config, + })), + avatarModels: payload.avatarModels?.map(model => ({ + id: nanoid(), + characterId: id, + name: model.name, + type: model.type, + description: model.description, + config: model.config, + createdAt: now, + updatedAt: now, + })), + i18n: payload.i18n?.map(item => ({ + id: nanoid(), + characterId: id, + language: item.language, + name: item.name, + description: item.description, + tags: item.tags, + createdAt: now, + updatedAt: now, + })), + prompts: payload.prompts?.map(prompt => ({ + id: nanoid(), + characterId: id, + language: prompt.language, + type: prompt.type, + content: prompt.content, + })), + likes: [], + bookmarks: [], + }) +} export const useCharacterStore = defineStore('characters', () => { const characters = ref>(new Map()) + const auth = useAuthStore() async function fetchList(all: boolean = false) { // Load from storage immediately @@ -22,117 +86,204 @@ export const useCharacterStore = defineStore('characters', () => { } } - return useAsyncState(async () => { - const res = await client.api.characters.$get({ - query: { all: String(all) }, - }) - if (!res.ok) { - throw new Error('Failed to fetch characters') - } - const data = await res.json() + return useLocalFirstRequest({ + local: async () => undefined, + remote: async () => { + const res = await client.api.characters.$get({ + query: { all: String(all) }, + }) + if (!res.ok) { + throw new Error('Failed to fetch characters') + } + const data = await res.json() - characters.value.clear() - const parsedData: Character[] = [] - for (const char of data) { - const parsed = parse(CharacterWithRelationsSchema, char) - characters.value.set(char.id, parsed) - parsedData.push(parsed) - } - await charactersRepo.saveAll(parsedData) - }, { immediate: true }) + characters.value.clear() + const parsedData: Character[] = [] + for (const char of data) { + const parsed = parse(CharacterWithRelationsSchema, char) + characters.value.set(char.id, parsed) + parsedData.push(parsed) + } + await charactersRepo.saveAll(parsedData) + }, + }) } async function fetchById(id: string) { - return useAsyncState(async () => { - const res = await client.api.characters[':id'].$get({ - param: { id }, - }) - if (!res.ok) { - throw new Error('Failed to fetch character') - } - const data = await res.json() - const character = parse(CharacterWithRelationsSchema, data) + return useLocalFirstRequest({ + local: async () => { + const cached = characters.value.get(id) ?? (await charactersRepo.getAll()).find(char => char.id === id) + if (cached) { + characters.value.set(cached.id, cached) + } + return cached + }, + remote: async () => { + const res = await client.api.characters[':id'].$get({ + param: { id }, + }) + if (!res.ok) { + throw new Error('Failed to fetch character') + } + const data = await res.json() + const character = parse(CharacterWithRelationsSchema, data) - characters.value.set(character.id, character) - await charactersRepo.upsert(character) - return character - }, { immediate: true }) + characters.value.set(character.id, character) + await charactersRepo.upsert(character) + return character + }, + }) } async function create(payload: CreateCharacterPayload) { - return useAsyncState(async () => { - const res = await client.api.characters.$post({ - json: payload, - }) - if (!res.ok) { - throw new Error('Failed to create character') - } - const data = await res.json() - const character = parse(CharacterWithRelationsSchema, data) + let localCharacter: Character + return useLocalFirstRequest({ + local: async () => { + localCharacter = buildLocalCharacter(auth.userId, payload) + characters.value.set(localCharacter.id, localCharacter) + await charactersRepo.upsert(localCharacter) + return localCharacter + }, + remote: async () => { + const res = await client.api.characters.$post({ + json: payload, + }) + if (!res.ok) { + throw new Error('Failed to create character') + } + const data = await res.json() + const character = parse(CharacterWithRelationsSchema, data) - characters.value.set(character.id, character) - await charactersRepo.upsert(character) - return character - }, { immediate: true }) + // Replace local temp character with remote data + characters.value.delete(localCharacter.id) + characters.value.set(character.id, character) + await charactersRepo.remove(localCharacter.id) + await charactersRepo.upsert(character) + return character + }, + }) } async function update(id: string, payload: UpdateCharacterPayload) { - return useAsyncState(async () => { - const res = await (client.api.characters[':id'].$patch)({ - param: { id }, - // @ts-expect-error FIXME: hono client typing misses json option for this route - json: payload, - }) - if (!res.ok) { - throw new Error('Failed to update character') - } - const data = await res.json() - const character = parse(CharacterWithRelationsSchema, data) + return useLocalFirstRequest({ + local: async () => { + const character = characters.value.get(id) + if (!character) { + return + } + if (payload.version !== undefined) + character.version = payload.version + if (payload.coverUrl !== undefined) + character.coverUrl = payload.coverUrl + if (payload.characterId !== undefined) + character.characterId = payload.characterId + character.updatedAt = new Date() + characters.value.set(character.id, character) + await charactersRepo.upsert(character) + return character + }, + remote: async () => { + const res = await (client.api.characters[':id'].$patch)({ + param: { id }, + // @ts-expect-error FIXME: hono client typing misses json option for this route + json: payload, + }) + if (!res.ok) { + throw new Error('Failed to update character') + } + const data = await res.json() + const character = parse(CharacterWithRelationsSchema, data) - characters.value.set(character.id, character) - await charactersRepo.upsert(character) - return character - }, { immediate: true }) + characters.value.set(character.id, character) + await charactersRepo.upsert(character) + return character + }, + }) } async function remove(id: string) { - return useAsyncState(async () => { - const res = await client.api.characters[':id'].$delete({ - param: { id }, - }) - if (!res.ok) { - throw new Error('Failed to remove character') - } - - characters.value.delete(id) - await charactersRepo.remove(id) - }, { immediate: true }) + return useLocalFirstRequest({ + local: async () => { + characters.value.delete(id) + await charactersRepo.remove(id) + }, + remote: async () => { + const res = await client.api.characters[':id'].$delete({ + param: { id }, + }) + if (!res.ok) { + throw new Error('Failed to remove character') + } + }, + }) } async function like(id: string) { - return useAsyncState(async () => { - const res = await client.api.characters[':id'].like.$post({ - param: { id }, - }) - if (!res.ok) { - throw new Error('Failed to like character') - } + return useLocalFirstRequest({ + local: async () => { + const character = characters.value.get(id) + if (!character) { + return + } + const likes = character.likes ?? [] + if (!likes.some(item => item.userId === auth.userId)) { + likes.push({ userId: auth.userId, characterId: id }) + character.likes = likes + character.likesCount += 1 + character.updatedAt = new Date() + characters.value.set(character.id, character) + await charactersRepo.upsert(character) + } + }, + remote: async () => { + const res = await client.api.characters[':id'].like.$post({ + param: { id }, + }) + if (!res.ok) { + throw new Error('Failed to like character') + } - await fetchById(id) - }, { immediate: true }) + const data = await res.json() + const character = parse(CharacterWithRelationsSchema, data) + characters.value.set(character.id, character) + await charactersRepo.upsert(character) + return character + }, + }) } async function bookmark(id: string) { - return useAsyncState(async () => { - const res = await client.api.characters[':id'].bookmark.$post({ - param: { id }, - }) - if (!res.ok) { - throw new Error('Failed to bookmark character') - } + return useLocalFirstRequest({ + local: async () => { + const character = characters.value.get(id) + if (!character) { + return + } + const bookmarks = character.bookmarks ?? [] + if (!bookmarks.some(item => item.userId === auth.userId)) { + bookmarks.push({ userId: auth.userId, characterId: id }) + character.bookmarks = bookmarks + character.bookmarksCount += 1 + character.updatedAt = new Date() + characters.value.set(character.id, character) + await charactersRepo.upsert(character) + } + }, + remote: async () => { + const res = await client.api.characters[':id'].bookmark.$post({ + param: { id }, + }) + if (!res.ok) { + throw new Error('Failed to bookmark character') + } - await fetchById(id) - }, { immediate: true }) + const data = await res.json() + const character = parse(CharacterWithRelationsSchema, data) + characters.value.set(character.id, character) + await charactersRepo.upsert(character) + return character + }, + }) } function getCharacter(id: string) { diff --git a/packages/stage-ui/src/stores/provider-catalog.ts b/packages/stage-ui/src/stores/provider-catalog.ts index dfdfb8122..959f82264 100644 --- a/packages/stage-ui/src/stores/provider-catalog.ts +++ b/packages/stage-ui/src/stores/provider-catalog.ts @@ -5,8 +5,7 @@ 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 { useLocalFirstRequest } from '../composables/use-local-first' import { providersRepo } from '../database/repos/providers.repo' import { getDefinedProvider, listProviders } from '../libs/providers/providers' @@ -21,27 +20,30 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => { configs.value = cached } - return useAsyncState(async () => { - const res = await client.api.providers.$get() - if (!res.ok) { - throw new Error('Failed to fetch providers') - } - const data = await res.json() - - const newConfigs: Record = {} - for (const item of data) { - newConfigs[item.id] = { - id: item.id, - definitionId: item.definitionId, - name: item.name, - config: item.config as Record, - validated: item.validated, - validationBypassed: item.validationBypassed, + return useLocalFirstRequest({ + local: async () => undefined, + remote: async () => { + const res = await client.api.providers.$get() + if (!res.ok) { + throw new Error('Failed to fetch providers') } - } - configs.value = newConfigs - await providersRepo.saveAll(newConfigs) - }, { immediate: true }) + const data = await res.json() + + const newConfigs: Record = {} + for (const item of data) { + newConfigs[item.id] = { + id: item.id, + definitionId: item.definitionId, + name: item.name, + config: item.config as Record, + validated: item.validated, + validationBypassed: item.validationBypassed, + } + } + configs.value = newConfigs + await providersRepo.saveAll(newConfigs) + }, + }) } async function addProvider(definitionId: string, initialConfig: Record = {}) { @@ -60,16 +62,13 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => { validationBypassed: false, } - return useOptimisticMutation({ - apply: async () => { + return useLocalFirstRequest({ + local: async () => { configs.value[id] = provider await providersRepo.upsert(provider) - return async () => { - delete configs.value[id] - await providersRepo.remove(id) - } + return provider }, - action: async () => { + remote: async () => { const res = await client.api.providers.$post({ json: { id, @@ -83,9 +82,7 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => { if (!res.ok) { throw new Error('Failed to add provider') } - return await res.json() - }, - onSuccess: async (item: any) => { + const item = await res.json() const finalProvider: ProviderCatalogProvider = { id: item.id, definitionId: item.definitionId, @@ -103,21 +100,16 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => { } async function removeProvider(providerId: string) { - const original = configs.value[providerId] - if (!original) { + if (!configs.value[providerId]) { return } - return useOptimisticMutation({ - apply: async () => { + return useLocalFirstRequest({ + local: async () => { delete configs.value[providerId] await providersRepo.remove(providerId) - return async () => { - configs.value[providerId] = original - await providersRepo.upsert(original) - } }, - action: async () => { + remote: async () => { const res = await client.api.providers[':id'].$delete({ param: { id: providerId }, }) @@ -134,24 +126,15 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => { return } - const originalConfig = { ...provider.config } - const originalValidated = provider.validated - const originalValidationBypassed = provider.validationBypassed - - return useOptimisticMutation({ - apply: async () => { + return useLocalFirstRequest({ + local: 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) - } + return provider }, - action: async () => { + remote: async () => { const res = await client.api.providers[':id'].$patch({ param: { id: providerId }, // @ts-expect-error hono client typing misses json option for this route @@ -164,14 +147,13 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => { if (!res.ok) { throw new Error('Failed to update provider config') } - return await res.json() - }, - onSuccess: async (item: any) => { + const item = await res.json() // 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 item }, }) }