feat(stage-ui): local-first storage (#922)

This commit is contained in:
RainbowBird
2026-01-09 18:28:40 +08:00
committed by GitHub
parent 26529bc36f
commit 6db260fcba
8 changed files with 230 additions and 21 deletions
+2
View File
@@ -94,6 +94,7 @@
"gpuu": "^1.0.6",
"hono": "catalog:",
"html2canvas": "^1.4.1",
"idb-keyval": "catalog:",
"localforage": "^1.10.0",
"mediabunny": "^1.27.2",
"nanoid": "^5.1.6",
@@ -114,6 +115,7 @@
"unist-builder": "^4.0.0",
"unist-util-visit": "^5.0.0",
"unspeech": "catalog:xsai",
"unstorage": "catalog:",
"uuid": "^13.0.0",
"valibot": "catalog:",
"vaul-vue": "^0.4.1",
@@ -0,0 +1,30 @@
import type { Character } from '../../types/character'
import { storage } from '../storage'
export const charactersRepo = {
async getAll() {
return await storage.getItem<Character[]>('local:characters') || []
},
async saveAll(characters: Character[]) {
await storage.setItem('local:characters', characters)
},
async upsert(character: Character) {
const all = await this.getAll()
const index = all.findIndex(c => c.id === character.id)
if (index > -1) {
all[index] = character
}
else {
all.push(character)
}
await this.saveAll(all)
},
async remove(id: string) {
const all = await this.getAll()
await this.saveAll(all.filter(c => c.id !== id))
},
}
@@ -0,0 +1,32 @@
import { storage } from '../storage'
export interface ProviderCatalogProvider {
id: string
definitionId: string
name: string
config: Record<string, any>
validated: boolean
validationBypassed: boolean
}
export const providersRepo = {
async getAll() {
return await storage.getItem<Record<string, ProviderCatalogProvider>>('local:providers') || {}
},
async saveAll(providers: Record<string, ProviderCatalogProvider>) {
await storage.setItem('local:providers', providers)
},
async upsert(provider: ProviderCatalogProvider) {
const all = await this.getAll()
all[provider.id] = provider
await this.saveAll(all)
},
async remove(id: string) {
const all = await this.getAll()
delete all[id]
await this.saveAll(all)
},
}
+11
View File
@@ -0,0 +1,11 @@
import indexedDbDriver from 'unstorage/drivers/indexedb'
import memoryDriver from 'unstorage/drivers/memory'
import { createStorage } from 'unstorage'
export const storage = createStorage({
driver: memoryDriver(),
})
storage.mount('local', indexedDbDriver({ base: 'airi-local' }))
storage.mount('outbox', indexedDbDriver({ base: 'airi-sync-queue' }))
+19 -1
View File
@@ -6,12 +6,22 @@ import { ref } from 'vue'
import { client } from '../composables/api'
import { useAsyncState } from '../composables/use-async-state'
import { charactersRepo } from '../database/repos/characters.repo'
import { CharacterWithRelationsSchema } from '../types/character'
export const useCharacterStore = defineStore('characters', () => {
const characters = ref<Map<string, Character>>(new Map())
async function fetchList(all: boolean = false) {
// Load from storage immediately
const cached = await charactersRepo.getAll()
if (cached.length > 0) {
characters.value.clear()
for (const char of cached) {
characters.value.set(char.id, char)
}
}
return useAsyncState(async () => {
const res = await client.api.characters.$get({
query: { all: String(all) },
@@ -22,9 +32,13 @@ export const useCharacterStore = defineStore('characters', () => {
const data = await res.json()
characters.value.clear()
const parsedData: Character[] = []
for (const char of data) {
characters.value.set(char.id, parse(CharacterWithRelationsSchema, char))
const parsed = parse(CharacterWithRelationsSchema, char)
characters.value.set(char.id, parsed)
parsedData.push(parsed)
}
await charactersRepo.saveAll(parsedData)
}, { immediate: true })
}
@@ -40,6 +54,7 @@ export const useCharacterStore = defineStore('characters', () => {
const character = parse(CharacterWithRelationsSchema, data)
characters.value.set(character.id, character)
await charactersRepo.upsert(character)
return character
}, { immediate: true })
}
@@ -56,6 +71,7 @@ export const useCharacterStore = defineStore('characters', () => {
const character = parse(CharacterWithRelationsSchema, data)
characters.value.set(character.id, character)
await charactersRepo.upsert(character)
return character
}, { immediate: true })
}
@@ -74,6 +90,7 @@ export const useCharacterStore = defineStore('characters', () => {
const character = parse(CharacterWithRelationsSchema, data)
characters.value.set(character.id, character)
await charactersRepo.upsert(character)
return character
}, { immediate: true })
}
@@ -88,6 +105,7 @@ export const useCharacterStore = defineStore('characters', () => {
}
characters.value.delete(id)
await charactersRepo.remove(id)
}, { immediate: true })
}
@@ -1,24 +1,24 @@
import type { ProviderCatalogProvider } from '../database/repos/providers.repo'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { client } from '../composables/api'
import { useAsyncState } from '../composables/use-async-state'
import { providersRepo } from '../database/repos/providers.repo'
import { getDefinedProvider, listProviders } from '../libs/providers/providers'
interface ProviderCatalogProvider {
id: string
definitionId: string
name: string
config: Record<string, any>
validated: boolean
validationBypassed: boolean
}
export const useProviderCatalogStore = defineStore('provider-catalog', () => {
const defs = computed(() => listProviders())
const configs = ref<Record<string, ProviderCatalogProvider>>({})
async function fetchList() {
// Load from storage immediately
const cached = await providersRepo.getAll()
if (Object.keys(cached).length > 0) {
configs.value = cached
}
return useAsyncState(async () => {
const res = await client.api.providers.$get()
if (!res.ok) {
@@ -38,6 +38,7 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
}
}
configs.value = newConfigs
await providersRepo.saveAll(newConfigs)
}, { immediate: true })
}
@@ -61,7 +62,7 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
}
const item = await res.json()
configs.value[item.id] = {
const provider: ProviderCatalogProvider = {
id: item.id,
definitionId: item.definitionId,
name: item.name,
@@ -69,6 +70,8 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
validated: item.validated,
validationBypassed: item.validationBypassed,
}
configs.value[item.id] = provider
await providersRepo.upsert(provider)
return item
}, { immediate: true })
}
@@ -82,6 +85,7 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
throw new Error('Failed to remove provider')
}
delete configs.value[providerId]
await providersRepo.remove(providerId)
}, { immediate: true })
}
@@ -105,9 +109,11 @@ export const useProviderCatalogStore = defineStore('provider-catalog', () => {
}
const item = await res.json()
configs.value[providerId].config = { ...item.config as Record<string, any> }
configs.value[providerId].validated = item.validated
configs.value[providerId].validationBypassed = item.validationBypassed
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 })
}
+115 -7
View File
@@ -111,6 +111,9 @@ catalogs:
hono:
specifier: 4.11.3
version: 4.11.3
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
injeca:
specifier: ^0.1.5
version: 0.1.5
@@ -141,6 +144,9 @@ catalogs:
unplugin-info:
specifier: ^1.2.4
version: 1.2.4
unstorage:
specifier: ^1.17.3
version: 1.17.3
valibot:
specifier: 1.2.0
version: 1.2.0
@@ -1726,7 +1732,7 @@ importers:
version: 3.0.7(@nuxt/kit@3.20.2(magicast@0.5.1))(esbuild@0.27.2)(rolldown@1.0.0-beta.53)(rollup@4.54.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vitepress:
specifier: ^2.0.0-alpha.15
version: 2.0.0-alpha.15(@algolia/client-search@5.46.2)(@types/node@24.10.4)(change-case@5.4.4)(fuse.js@7.1.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(nprogress@0.2.0)(postcss@8.5.6)(react@19.2.3)(search-insights@2.17.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
version: 2.0.0-alpha.15(@algolia/client-search@5.46.2)(@types/node@24.10.4)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(nprogress@0.2.0)(postcss@8.5.6)(react@19.2.3)(search-insights@2.17.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
vue-tsc:
specifier: ^3.1.8
version: 3.2.1(typescript@5.9.3)
@@ -2274,6 +2280,9 @@ importers:
html2canvas:
specifier: ^1.4.1
version: 1.4.1
idb-keyval:
specifier: 'catalog:'
version: 6.2.2
localforage:
specifier: ^1.10.0
version: 1.10.0
@@ -2334,6 +2343,9 @@ importers:
unspeech:
specifier: catalog:xsai
version: 0.1.11
unstorage:
specifier: 'catalog:'
version: 1.17.3(idb-keyval@6.2.2)
uuid:
specifier: ^13.0.0
version: 13.0.0
@@ -2884,7 +2896,7 @@ importers:
version: 66.5.11
'@wxt-dev/module-vue':
specifier: ^1.0.3
version: 1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vue-tsc:
specifier: ^3.1.8
version: 3.2.1(typescript@5.9.3)
@@ -9154,6 +9166,10 @@ packages:
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
apache-arrow@17.0.0:
resolution: {integrity: sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==}
hasBin: true
@@ -11854,6 +11870,9 @@ packages:
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
engines: {node: '>=0.10.0'}
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -15569,6 +15588,68 @@ packages:
unspeech@0.1.11:
resolution: {integrity: sha512-leRXPcR3ou4F1Jxio0ePyOv+/+mm++huuLHMN4f63/My/s6tiFPbZ8EwQcgxw0FVpiaH4auad5qkhWUD3xxzrQ==}
unstorage@1.17.3:
resolution: {integrity: sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q==}
peerDependencies:
'@azure/app-configuration': ^1.8.0
'@azure/cosmos': ^4.2.0
'@azure/data-tables': ^13.3.0
'@azure/identity': ^4.6.0
'@azure/keyvault-secrets': ^4.9.0
'@azure/storage-blob': ^12.26.0
'@capacitor/preferences': ^6.0.3 || ^7.0.0
'@deno/kv': '>=0.9.0'
'@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0
'@planetscale/database': ^1.19.0
'@upstash/redis': ^1.34.3
'@vercel/blob': '>=0.27.1'
'@vercel/functions': ^2.2.12 || ^3.0.0
'@vercel/kv': ^1.0.1
aws4fetch: ^1.0.20
db0: '>=0.2.1'
idb-keyval: ^6.2.1
ioredis: ^5.4.2
uploadthing: ^7.4.4
peerDependenciesMeta:
'@azure/app-configuration':
optional: true
'@azure/cosmos':
optional: true
'@azure/data-tables':
optional: true
'@azure/identity':
optional: true
'@azure/keyvault-secrets':
optional: true
'@azure/storage-blob':
optional: true
'@capacitor/preferences':
optional: true
'@deno/kv':
optional: true
'@netlify/blobs':
optional: true
'@planetscale/database':
optional: true
'@upstash/redis':
optional: true
'@vercel/blob':
optional: true
'@vercel/functions':
optional: true
'@vercel/kv':
optional: true
aws4fetch:
optional: true
db0:
optional: true
idb-keyval:
optional: true
ioredis:
optional: true
uploadthing:
optional: true
untildify@4.0.0:
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
engines: {node: '>=8'}
@@ -22111,6 +22192,12 @@ snapshots:
vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vue: 3.5.25(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vue: 3.5.25(typescript@5.9.3)
'@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)':
dependencies:
'@vitest/browser': 4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)
@@ -22785,7 +22872,7 @@ snapshots:
'@vueuse/shared': 14.1.0(vue@3.5.25(typescript@5.9.3))
vue: 3.5.25(typescript@5.9.3)
'@vueuse/integrations@14.1.0(change-case@5.4.4)(focus-trap@7.7.0)(fuse.js@7.1.0)(nprogress@0.2.0)(vue@3.5.25(typescript@5.9.3))':
'@vueuse/integrations@14.1.0(change-case@5.4.4)(focus-trap@7.7.0)(fuse.js@7.1.0)(idb-keyval@6.2.2)(nprogress@0.2.0)(vue@3.5.25(typescript@5.9.3))':
dependencies:
'@vueuse/core': 14.1.0(vue@3.5.25(typescript@5.9.3))
'@vueuse/shared': 14.1.0(vue@3.5.25(typescript@5.9.3))
@@ -22794,6 +22881,7 @@ snapshots:
change-case: 5.4.4
focus-trap: 7.7.0
fuse.js: 7.1.0
idb-keyval: 6.2.2
nprogress: 0.2.0
'@vueuse/metadata@10.11.1': {}
@@ -22856,9 +22944,9 @@ snapshots:
'@types/filesystem': 0.0.36
'@types/har-format': 1.2.16
'@wxt-dev/module-vue@1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
'@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
wxt: 0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- vite
@@ -23126,6 +23214,11 @@ snapshots:
any-promise@1.3.0: {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
apache-arrow@17.0.0:
dependencies:
'@swc/helpers': 0.5.18
@@ -26227,6 +26320,8 @@ snapshots:
dependencies:
safer-buffer: '@nolyfill/safer-buffer@1.0.44'
idb-keyval@6.2.2: {}
idb@7.1.1: {}
ieee754@1.2.1: {}
@@ -30753,6 +30848,19 @@ snapshots:
'@xsai-ext/providers': 0.4.0
'@xsai/shared': 0.4.0
unstorage@1.17.3(idb-keyval@6.2.2):
dependencies:
anymatch: 3.1.3
chokidar: 4.0.3
destr: 2.0.5
h3: 1.15.4
lru-cache: 10.4.3
node-fetch-native: 1.6.7
ofetch: 1.5.1
ufo: 1.6.1
optionalDependencies:
idb-keyval: 6.2.2
untildify@4.0.0: {}
untun@0.1.3:
@@ -31118,7 +31226,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vitepress@2.0.0-alpha.15(@algolia/client-search@5.46.2)(@types/node@24.10.4)(change-case@5.4.4)(fuse.js@7.1.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(nprogress@0.2.0)(postcss@8.5.6)(react@19.2.3)(search-insights@2.17.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
vitepress@2.0.0-alpha.15(@algolia/client-search@5.46.2)(@types/node@24.10.4)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(nprogress@0.2.0)(postcss@8.5.6)(react@19.2.3)(search-insights@2.17.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
dependencies:
'@docsearch/css': 4.4.0
'@docsearch/js': 4.4.0(@algolia/client-search@5.46.2)(react@19.2.3)(search-insights@2.17.3)
@@ -31131,7 +31239,7 @@ snapshots:
'@vue/devtools-api': 8.0.5
'@vue/shared': 3.5.26
'@vueuse/core': 14.1.0(vue@3.5.25(typescript@5.9.3))
'@vueuse/integrations': 14.1.0(change-case@5.4.4)(focus-trap@7.7.0)(fuse.js@7.1.0)(nprogress@0.2.0)(vue@3.5.25(typescript@5.9.3))
'@vueuse/integrations': 14.1.0(change-case@5.4.4)(focus-trap@7.7.0)(fuse.js@7.1.0)(idb-keyval@6.2.2)(nprogress@0.2.0)(vue@3.5.25(typescript@5.9.3))
focus-trap: 7.7.0
mark.js: 8.11.1
minisearch: 7.2.0
+2
View File
@@ -58,6 +58,7 @@ catalog:
embla-carousel-vue: ^8.6.0
es-toolkit: 1.43.0
hono: 4.11.3
idb-keyval: ^6.2.2
injeca: ^0.1.5
is-network-error: ^1.3.0
nano-staged: ^0.9.0
@@ -68,6 +69,7 @@ catalog:
tsx: ^4.21.0
uncrypto: ^0.1.3
unplugin-info: ^1.2.4
unstorage: ^1.17.3
valibot: 1.2.0
vite-plugin-mkcert: ^1.17.9
vue-sonner: 2.0.9