feat(stage-ui): use local first request (#977)

This commit is contained in:
RainbowBird
2026-01-21 22:56:43 +08:00
committed by GitHub
parent 78afeeb82c
commit b4cc416472
5 changed files with 361 additions and 147 deletions
@@ -0,0 +1,59 @@
import { getActivePinia } from 'pinia'
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth'
async function canUseRemote(allowRemote?: () => boolean | Promise<boolean>) {
if (allowRemote)
return await allowRemote()
if (!getActivePinia())
return true
return useAuthStore().isAuthenticated
}
export interface UseLocalFirstRequestOptions<T> {
local: () => Promise<T> | T
remote: () => Promise<T>
allowRemote?: () => boolean | Promise<boolean>
lazy?: boolean
}
export function useLocalFirstRequest<T>(options: UseLocalFirstRequestOptions<T>) {
const { local, remote, allowRemote, lazy = false } = options
const state = ref<T>()
const isLoading = ref(false)
const error = ref<unknown>(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,
}
}
@@ -18,6 +18,14 @@ export interface UseOptimisticMutationOptions<T, R, E = unknown> {
* Optional callback on error. Rollback is handled automatically.
*/
onError?: (error?: E | null) => void | Promise<void>
/**
* Skip the action when this returns true.
*/
skipActionIf?: () => boolean | Promise<boolean>
/**
* Decide whether to rollback after an error.
*/
shouldRollback?: (error: E) => boolean | Promise<boolean>
/**
* Whether to execute the action lazily.
@@ -31,10 +39,21 @@ export interface UseOptimisticMutationOptions<T, R, E = unknown> {
* 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
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<T, R = T, E = unknown>(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) {
+2
View File
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User>()
const session = ref<Session>()
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,
+239 -88
View File
@@ -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<Map<string, Character>>(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) {
@@ -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<string, ProviderCatalogProvider> = {}
for (const item of data) {
newConfigs[item.id] = {
id: item.id,
definitionId: item.definitionId,
name: item.name,
config: item.config as Record<string, any>,
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<string, ProviderCatalogProvider> = {}
for (const item of data) {
newConfigs[item.id] = {
id: item.id,
definitionId: item.definitionId,
name: item.name,
config: item.config as Record<string, any>,
validated: item.validated,
validationBypassed: item.validationBypassed,
}
}
configs.value = newConfigs
await providersRepo.saveAll(newConfigs)
},
})
}
async function addProvider(definitionId: string, initialConfig: Record<string, any> = {}) {
@@ -60,16 +62,13 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
validationBypassed: false,
}
return useOptimisticMutation<any, any>({
apply: async () => {
return useLocalFirstRequest<any>({
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<void, void>({
apply: async () => {
return useLocalFirstRequest<void>({
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<any, void>({
apply: async () => {
return useLocalFirstRequest<any>({
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<string, any> }
provider.validated = item.validated
provider.validationBypassed = item.validationBypassed
await providersRepo.upsert(provider)
return item
},
})
}