refactor(stage-ui): new structure for models, services, stores

This commit is contained in:
Neko Ayaka
2026-05-08 17:03:46 +08:00
parent 34e34728c9
commit 37433b98a2
23 changed files with 1904 additions and 488 deletions
+7
View File
@@ -123,12 +123,19 @@ Concise but detailed reference for contributors working across the `moeru-ai/air
- When writing tests, prefer line-by-line `expect` or assertion statements.
- Avoid writing tests for impossible runtime states, such as `expect` against constants that never change, or asserting object mutations that can only happen inside the same Vitest case setup.
- Avoid mocking `globalThis` or built-in modules by directly using `Object.defineProperty(...)`. If needed, use `node:worker_threads` to load another worker and simulate that situation, or build a mini CLI to reproduce and verify behavior. For DOM and Web Platform APIs, prefer Vitest browser mode instead of hard-mocking platform internals. If tests already use those patterns, progressively refactor them.
- Do not use Vitest mocks, hoisting, dynamic imports, `as unknown as`, or test-only alternate import paths to maliciously bypass real import problems. If a test cannot import a module, investigate the actual compile/runtime boundary: package exports, side effects, mixed Node/browser type dependencies, circular imports, and whether the public module shape is wrong. Fix the boundary instead of hiding the failure in the test.
## TypeScript / IPC / Tools
- Keep JSON Schemas provider-compliant (explicit `type: object`, required fields; avoid unbounded records).
- Favor functional patterns + DI (`injeca`); avoid new class hierarchies unless extending browser APIs (classes are harder to mock/test).
- Centralize Eventa contracts; use `@moeru/eventa` for all events.
- Import types from the module or package that owns the contract. Do not redeclare external/public contracts locally just to use a narrower subset, and do not route type imports through local runtime assembly modules when the original side-effect-free type source is available.
- Do not use inline type imports such as `typeof import('...').x` or `import('...').Type` to avoid normal module boundaries. Export explicit shared types from the owning module, import external contract types from their owning package, or split a dedicated side-effect-free type module when runtime imports would pull in the wrong environment.
- Do not directly modify or override `tsconfig.json` to make an import/type error disappear. First investigate compilation behavior, `package.json` `exports` declarations, type declarations, and whether the dependency exposes the intended browser/node entrypoints.
- When Node-only and browser-only types are mixed through one import chain, split the type declarations into a neutral type file and keep runtime modules environment-specific. Avoid importing values from modules that carry side effects just to obtain types.
- If a wrong export or missing export causes an error, trace the full import chain and side-effect chain before changing imports at the leaf. Prefer fixing package/module exports and the owning boundary over adding local workaround imports.
- Treat circular imports as a design problem. If a cycle appears, first reconsider ownership, module boundaries, and whether shared types or pure helpers need to move. If the cycle cannot be resolved confidently, ask the user for direction before continuing.
- When a user asks to use a specific tool or dependency, first check Context7 docs with the search tool, then inspect actual usage of the dependency in this repo.
- If multiple names are returned from Context7 without a clear distinction, ask the user to choose or confirm the desired one.
- If docs conflict with typecheck results, inspect the dependency source under `node_modules` to diagnose root cause and fix types/bugs.
+1
View File
@@ -53,6 +53,7 @@
"@intlify/core": "catalog:",
"@moeru/eventa": "catalog:",
"@moeru/std": "catalog:",
"@pinia/colada": "catalog:",
"@proj-airi/audio": "workspace:^",
"@proj-airi/ccc": "workspace:^",
"@proj-airi/drizzle-duckdb-wasm": "catalog:",
@@ -4,6 +4,7 @@ import type { RouteRecordRaw } from 'vue-router'
import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -49,6 +50,7 @@ createApp(App)
.use(autoAnimatePlugin as unknown as Plugin)
.use(router)
.use(pinia)
.use(PiniaColada)
.use(i18n)
.use(Tres)
.mount('#app')
+1
View File
@@ -24,6 +24,7 @@
"@huggingface/transformers": "^3.8.1",
"@moeru/eventa": "catalog:",
"@moeru/std": "catalog:",
"@pinia/colada": "catalog:",
"@proj-airi/audio": "workspace:^",
"@proj-airi/ccc": "workspace:^",
"@proj-airi/drizzle-duckdb-wasm": "catalog:",
+2
View File
@@ -5,6 +5,7 @@ import Tres from '@tresjs/core'
import NProgress from 'nprogress'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
@@ -51,6 +52,7 @@ createApp(App)
.use(autoAnimatePlugin as unknown as Plugin)
.use(router)
.use(pinia)
.use(PiniaColada)
.use(i18n)
.use(Tres)
.mount('#app')
+1
View File
@@ -69,6 +69,7 @@
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/core": "^2.7.0",
"@opentelemetry/sdk-trace-base": "^2.7.0",
"@pinia/colada": "catalog:",
"@proj-airi/audio": "workspace:^",
"@proj-airi/ccc": "workspace:^",
"@proj-airi/chromatic": "^1.1.1",
+2
View File
@@ -8,3 +8,5 @@ import { SERVER_URL } from '../libs/server'
export const client = hc<AppType>(SERVER_URL, {
fetch: authedFetch,
})
export type StageApiClient = typeof client
@@ -1,30 +0,0 @@
import type { Character } from '../../types/character'
import { storage } from '../storage'
export const charactersRepo = {
async getAll() {
return await storage.getItemRaw<Character[]>('local:characters') || []
},
async saveAll(characters: Character[]) {
await storage.setItemRaw('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))
},
}
@@ -1,32 +0,0 @@
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.getItemRaw<Record<string, ProviderCatalogProvider>>('local:providers') || {}
},
async saveAll(providers: Record<string, ProviderCatalogProvider>) {
await storage.setItemRaw('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)
},
}
@@ -0,0 +1,124 @@
import type { Character } from '../types/character'
import memoryDriver from 'unstorage/drivers/memory'
import { createStorage } from 'unstorage'
import { beforeEach, describe, expect, it } from 'vitest'
import { createCharactersModel } from './characters'
const character = {
id: 'character-1',
version: '1',
coverUrl: 'cover.png',
avatarUrl: undefined,
characterAvatarUrl: undefined,
coverBackgroundUrl: undefined,
creatorRole: undefined,
priceCredit: '0',
likesCount: 0,
bookmarksCount: 0,
interactionsCount: 0,
forksCount: 0,
creatorId: 'user-1',
ownerId: 'user-1',
characterId: 'airi',
createdAt: new Date('2026-05-08T00:00:00.000Z'),
updatedAt: new Date('2026-05-08T00:00:00.000Z'),
deletedAt: undefined,
capabilities: [],
avatarModels: [],
i18n: [],
prompts: [],
likes: [],
bookmarks: [],
} satisfies Character
/**
* @example
* describe('models characters', () => {})
*/
describe('models characters', () => {
let store: ReturnType<typeof createStorage>
let characters: ReturnType<typeof createCharactersModel>
beforeEach(() => {
store = createStorage({
driver: memoryDriver(),
})
characters = createCharactersModel({ storage: store })
})
/**
* @example
* expect(await characters.list()).toEqual([])
*/
it('lists characters from the existing local storage key', async () => {
await store.setItemRaw('local:characters', [character])
await expect(characters.list()).resolves.toEqual([character])
})
/**
* @example
* await characters.saveAll([character])
*/
it('saves all characters to the existing local storage key', async () => {
await characters.saveAll([character])
await expect(store.getItemRaw('local:characters')).resolves.toEqual([character])
})
/**
* @example
* await characters.upsert(character)
*/
it('upserts a character by id', async () => {
await store.setItemRaw('local:characters', [{ ...character, characterId: 'old' }])
await characters.upsert(character)
await expect(store.getItemRaw('local:characters')).resolves.toEqual([character])
})
/**
* @example
* await characters.remove('character-1')
*/
it('removes a character by id', async () => {
await store.setItemRaw('local:characters', [character])
await characters.remove('character-1')
await expect(store.getItemRaw('local:characters')).resolves.toEqual([])
})
/**
* @example
* await expect(characters.list({ abortSignal: signal })).rejects.toThrow()
*/
it('throws before local IO when aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(characters.list({ abortSignal: controller.signal })).rejects.toThrow()
})
/**
* @example
* await expect(characters.upsert(character, { abortSignal })).rejects.toThrow()
*/
it('throws after local read before follow-up writes when aborted', async () => {
await store.setItemRaw('local:characters', [])
const controller = new AbortController()
const originalGetItemRaw = store.getItemRaw.bind(store)
store.getItemRaw = async (...args) => {
const value = await originalGetItemRaw(...args)
controller.abort()
return value
}
await expect(characters.upsert(character, { abortSignal: controller.signal })).rejects.toThrow()
await expect(store.getItemRaw('local:characters')).resolves.toEqual([])
})
})
+111
View File
@@ -0,0 +1,111 @@
import type { Storage, StorageValue } from 'unstorage'
import type { Character } from '../types/character'
import { storage } from '../database/storage'
const STORAGE_KEY = 'local:characters'
/**
* Options shared by character model persistence operations.
*/
export interface CharacterModelOptions {
/**
* Cancels the operation before local storage IO starts.
*/
abortSignal?: AbortSignal
}
/**
* Local persistence boundary for characters.
*/
export interface CharactersModel {
/**
* Lists locally persisted characters.
*/
list: (options?: CharacterModelOptions) => Promise<Character[]>
/**
* Replaces the locally persisted character snapshot.
*/
saveAll: (characters: Character[], options?: CharacterModelOptions) => Promise<void>
/**
* Inserts or replaces one locally persisted character by `id`.
*/
upsert: (character: Character, options?: CharacterModelOptions) => Promise<void>
/**
* Removes one locally persisted character by `id`.
*/
remove: (id: string, options?: CharacterModelOptions) => Promise<void>
}
/**
* Runtime dependencies required to create the character persistence model.
*/
export interface CreateCharactersModelParams {
/**
* Unstorage-compatible backend used for the existing local character snapshot.
*/
storage: Storage<StorageValue>
}
/**
* Creates a local persistence boundary for characters.
*
* Use when:
* - Wiring a runtime-specific storage implementation.
* - Testing character persistence without mocking module imports.
*
* Expects:
* - `params.storage` is an unstorage-compatible storage instance.
*
* Returns:
* - A character model that reads and writes the existing `local:characters` key.
*/
export function createCharactersModel(params: CreateCharactersModelParams): CharactersModel {
const { storage: store } = params
async function list(options?: CharacterModelOptions): Promise<Character[]> {
options?.abortSignal?.throwIfAborted()
const characters = await store.getItemRaw<Character[]>(STORAGE_KEY) || []
options?.abortSignal?.throwIfAborted()
return characters
}
async function saveAll(characters: Character[], options?: CharacterModelOptions): Promise<void> {
options?.abortSignal?.throwIfAborted()
await store.setItemRaw(STORAGE_KEY, characters)
options?.abortSignal?.throwIfAborted()
}
async function upsert(character: Character, options?: CharacterModelOptions): Promise<void> {
options?.abortSignal?.throwIfAborted()
const all = await list(options)
options?.abortSignal?.throwIfAborted()
const index = all.findIndex(item => item.id === character.id)
if (index > -1) {
all[index] = character
}
else {
all.push(character)
}
await saveAll(all, options)
}
async function remove(id: string, options?: CharacterModelOptions): Promise<void> {
options?.abortSignal?.throwIfAborted()
const all = await list(options)
options?.abortSignal?.throwIfAborted()
await saveAll(all.filter(character => character.id !== id), options)
}
return {
list,
saveAll,
upsert,
remove,
}
}
export const charactersModel = createCharactersModel({ storage })
@@ -0,0 +1,104 @@
import type { InferenceServiceProvider } from './inference-service-providers'
import memoryDriver from 'unstorage/drivers/memory'
import { createStorage } from 'unstorage'
import { beforeEach, describe, expect, it } from 'vitest'
import { createInferenceServiceProvidersModel } from './inference-service-providers'
const provider = {
id: 'provider-1',
definitionId: 'openai-compatible',
name: 'OpenAI Compatible',
config: { apiKey: 'sk-test' },
validated: true,
validationBypassed: false,
} satisfies InferenceServiceProvider
/**
* @example
* describe('models inference-service-providers', () => {})
*/
describe('models inference-service-providers', () => {
let store: ReturnType<typeof createStorage>
let providers: ReturnType<typeof createInferenceServiceProvidersModel>
beforeEach(() => {
store = createStorage({
driver: memoryDriver(),
})
providers = createInferenceServiceProvidersModel({ storage: store })
})
/**
* @example
* expect(await providers.list()).toEqual({})
*/
it('lists providers from the existing local storage key', async () => {
await store.setItemRaw('local:providers', { [provider.id]: provider })
await expect(providers.list()).resolves.toEqual({ [provider.id]: provider })
})
/**
* @example
* await providers.saveAll({ [provider.id]: provider })
*/
it('saves all providers to the existing local storage key', async () => {
await providers.saveAll({ [provider.id]: provider })
await expect(store.getItemRaw('local:providers')).resolves.toEqual({ [provider.id]: provider })
})
/**
* @example
* await providers.upsert(provider)
*/
it('upserts a provider by id', async () => {
await providers.upsert(provider)
await expect(store.getItemRaw('local:providers')).resolves.toEqual({ [provider.id]: provider })
})
/**
* @example
* await providers.remove('provider-1')
*/
it('removes a provider by id', async () => {
await store.setItemRaw('local:providers', { [provider.id]: provider })
await providers.remove(provider.id)
await expect(store.getItemRaw('local:providers')).resolves.toEqual({})
})
/**
* @example
* await expect(providers.list({ abortSignal: signal })).rejects.toThrow()
*/
it('throws before local IO when aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(providers.list({ abortSignal: controller.signal })).rejects.toThrow()
})
/**
* @example
* await expect(providers.upsert(provider, { abortSignal })).rejects.toThrow()
*/
it('throws after local read before follow-up writes when aborted', async () => {
await store.setItemRaw('local:providers', {})
const controller = new AbortController()
const originalGetItemRaw = store.getItemRaw.bind(store)
store.getItemRaw = async (...args) => {
const value = await originalGetItemRaw(...args)
controller.abort()
return value
}
await expect(providers.upsert(provider, { abortSignal: controller.signal })).rejects.toThrow()
await expect(store.getItemRaw('local:providers')).resolves.toEqual({})
})
})
@@ -0,0 +1,122 @@
import type { Storage, StorageValue } from 'unstorage'
import { storage } from '../database/storage'
const STORAGE_KEY = 'local:providers'
/**
* Persisted inference service provider configuration.
*/
export interface InferenceServiceProvider {
/** Stable provider instance id. */
id: string
/** Provider definition id from the built-in provider registry. */
definitionId: string
/** Display name copied from the definition or server response. */
name: string
/** Provider-specific configuration values. */
config: Record<string, unknown>
/** Whether the provider config has passed validation. */
validated: boolean
/** Whether validation was intentionally bypassed by the user. */
validationBypassed: boolean
}
/**
* Options shared by inference service provider model persistence operations.
*/
export interface InferenceServiceProviderModelOptions {
/**
* Cancels the operation before local storage IO starts.
*/
abortSignal?: AbortSignal
}
export type InferenceServiceProviders = Record<string, InferenceServiceProvider>
/**
* Local persistence boundary for inference service providers.
*/
export interface InferenceServiceProvidersModel {
/**
* Lists locally persisted inference service providers.
*/
list: (options?: InferenceServiceProviderModelOptions) => Promise<InferenceServiceProviders>
/**
* Replaces the locally persisted inference service provider snapshot.
*/
saveAll: (providers: InferenceServiceProviders, options?: InferenceServiceProviderModelOptions) => Promise<void>
/**
* Inserts or replaces one locally persisted inference service provider by `id`.
*/
upsert: (provider: InferenceServiceProvider, options?: InferenceServiceProviderModelOptions) => Promise<void>
/**
* Removes one locally persisted inference service provider by `id`.
*/
remove: (id: string, options?: InferenceServiceProviderModelOptions) => Promise<void>
}
/**
* Runtime dependencies required to create the inference service provider persistence model.
*/
export interface CreateInferenceServiceProvidersModelParams {
/**
* Unstorage-compatible backend used for the existing local provider snapshot.
*/
storage: Storage<StorageValue>
}
/**
* Creates a local persistence boundary for inference service providers.
*
* Use when:
* - Wiring a runtime-specific storage implementation.
* - Testing provider persistence without mocking module imports.
*
* Expects:
* - `params.storage` is an unstorage-compatible storage instance.
*
* Returns:
* - A provider model that reads and writes the existing `local:providers` key.
*/
export function createInferenceServiceProvidersModel(params: CreateInferenceServiceProvidersModelParams): InferenceServiceProvidersModel {
const { storage: store } = params
async function list(options?: InferenceServiceProviderModelOptions): Promise<InferenceServiceProviders> {
options?.abortSignal?.throwIfAborted()
const providers = await store.getItemRaw<InferenceServiceProviders>(STORAGE_KEY) || {}
options?.abortSignal?.throwIfAborted()
return providers
}
async function saveAll(providers: InferenceServiceProviders, options?: InferenceServiceProviderModelOptions): Promise<void> {
options?.abortSignal?.throwIfAborted()
await store.setItemRaw(STORAGE_KEY, providers)
options?.abortSignal?.throwIfAborted()
}
async function upsert(provider: InferenceServiceProvider, options?: InferenceServiceProviderModelOptions): Promise<void> {
options?.abortSignal?.throwIfAborted()
const all = await list(options)
options?.abortSignal?.throwIfAborted()
all[provider.id] = provider
await saveAll(all, options)
}
async function remove(id: string, options?: InferenceServiceProviderModelOptions): Promise<void> {
options?.abortSignal?.throwIfAborted()
const all = await list(options)
options?.abortSignal?.throwIfAborted()
delete all[id]
await saveAll(all, options)
}
return {
list,
saveAll,
upsert,
remove,
}
}
export const inferenceServiceProvidersModel = createInferenceServiceProvidersModel({ storage })
@@ -0,0 +1,102 @@
import type { CreateCharacterPayload } from '../types/character'
import { describe, expect, it, vi } from 'vitest'
import { charactersService } from './characters'
const payload = {
character: {
version: '1',
coverUrl: 'cover.png',
characterId: 'airi',
},
capabilities: [],
avatarModels: [],
i18n: [],
prompts: [],
} satisfies CreateCharacterPayload
/**
* @example
* describe('services characters', () => {})
*/
describe('services characters', () => {
/**
* @example
* const character = charactersService.buildLocal('user-1', payload)
*/
it('builds a schema-valid local character with owner fields', () => {
const character = charactersService.buildLocal('user-1', payload)
expect(character.id).toBeDefined()
expect(character.creatorId).toBe('user-1')
expect(character.ownerId).toBe('user-1')
expect(character.characterId).toBe('airi')
expect(character.likes).toEqual([])
expect(character.bookmarks).toEqual([])
})
/**
* @example
* await charactersService.fetchRemote(client, { all: true })
*/
it('fetches remote characters and parses them', async () => {
const built = charactersService.buildLocal('user-1', payload)
const client = {
api: {
v1: {
characters: {
'$get': vi.fn(async () => ({ ok: true, json: async () => [built] })),
'$post': vi.fn(async () => ({ ok: true, json: async () => built })),
':id': {
$delete: vi.fn(async () => ({ ok: true })),
$get: vi.fn(async () => ({ ok: true, json: async () => built })),
$patch: vi.fn(async () => ({ ok: true, json: async () => built })),
bookmark: {
$post: vi.fn(async () => ({ ok: true, json: async () => built })),
},
like: {
$post: vi.fn(async () => ({ ok: true, json: async () => built })),
},
},
},
},
},
}
await expect(charactersService.fetchRemote(client, { all: true })).resolves.toEqual([built])
expect(client.api.v1.characters.$get).toHaveBeenCalledWith({ query: { all: 'true' } }, undefined)
})
/**
* @example
* await expect(charactersService.fetchRemote(client, {}, { abortSignal })).rejects.toThrow()
*/
it('throws before remote work when aborted', async () => {
const controller = new AbortController()
controller.abort()
const client = {
api: {
v1: {
characters: {
'$get': vi.fn(),
'$post': vi.fn(),
':id': {
$delete: vi.fn(),
$get: vi.fn(),
$patch: vi.fn(),
bookmark: {
$post: vi.fn(),
},
like: {
$post: vi.fn(),
},
},
},
},
},
}
await expect(charactersService.fetchRemote(client, {}, { abortSignal: controller.signal })).rejects.toThrow()
})
})
@@ -0,0 +1,248 @@
import type { Character, CreateCharacterPayload, UpdateCharacterPayload } from '../types/character'
import { nanoid } from 'nanoid'
import { parse as parseValibot } from 'valibot'
import { CharacterWithRelationsSchema } from '../types/character'
interface RequestOptions {
init: { signal: AbortSignal }
}
interface RemoteResponse<T> {
json: () => Promise<T>
ok: boolean
}
/**
* Remote character API surface required by the character service.
*/
export interface CharactersRemoteClient {
api: {
v1: {
characters: {
'$get': (params: { query: { all: string } }, options?: RequestOptions) => Promise<RemoteResponse<unknown[]>>
'$post': (params: { json: CreateCharacterPayload }, options?: RequestOptions) => Promise<RemoteResponse<unknown>>
':id': {
$delete: (params: { param: { id: string } }, options?: RequestOptions) => Promise<{ ok: boolean }>
$get: (params: { param: { id: string } }, options?: RequestOptions) => Promise<RemoteResponse<unknown>>
$patch: (params: { json: UpdateCharacterPayload, param: { id: string } }, options?: RequestOptions) => Promise<RemoteResponse<unknown>>
bookmark: {
$post: (params: { param: { id: string } }, options?: RequestOptions) => Promise<RemoteResponse<unknown>>
}
like: {
$post: (params: { param: { id: string } }, options?: RequestOptions) => Promise<RemoteResponse<unknown>>
}
}
}
}
}
}
/**
* Options shared by character service operations.
*/
export interface CharacterServiceOptions {
/**
* Cancels the operation before or after remote IO.
*/
abortSignal?: AbortSignal
}
/**
* Character domain operations used by controller stores.
*/
export interface CharactersService {
/** Builds an optimistic local character from a create payload. */
buildLocal: (userId: string, payload: CreateCharacterPayload) => Character
/** Fetches and parses the remote character list. */
fetchRemote: (client: CharactersRemoteClient, params: { all?: boolean }, options?: CharacterServiceOptions) => Promise<Character[]>
/** Fetches and parses one remote character. */
fetchRemoteById: (client: CharactersRemoteClient, id: string, options?: CharacterServiceOptions) => Promise<Character>
/** Creates and parses one remote character. */
createRemote: (client: CharactersRemoteClient, payload: CreateCharacterPayload, options?: CharacterServiceOptions) => Promise<Character>
/** Updates and parses one remote character. */
updateRemote: (client: CharactersRemoteClient, id: string, payload: UpdateCharacterPayload, options?: CharacterServiceOptions) => Promise<Character>
/** Removes one remote character. */
removeRemote: (client: CharactersRemoteClient, id: string, options?: CharacterServiceOptions) => Promise<void>
/** Likes and parses one remote character. */
likeRemote: (client: CharactersRemoteClient, id: string, options?: CharacterServiceOptions) => Promise<Character>
/** Bookmarks and parses one remote character. */
bookmarkRemote: (client: CharactersRemoteClient, id: string, options?: CharacterServiceOptions) => Promise<Character>
}
/**
* Creates the character service facade consumed by controller stores.
*
* Use when:
* - Wiring controller stores to character domain operations.
* - Tests need to replace the whole service surface with one mock object.
*
* Expects:
* - No runtime dependencies are required yet.
*
* Returns:
* - A stable object containing character domain operations.
*/
export function createCharactersService(): CharactersService {
function requestOptions(options?: CharacterServiceOptions): RequestOptions | undefined {
return options?.abortSignal ? { init: { signal: options.abortSignal } } : undefined
}
function parse(value: unknown): Character {
return parseValibot(CharacterWithRelationsSchema, value)
}
function buildLocal(userId: string, payload: CreateCharacterPayload): Character {
const id = payload.character.id ?? nanoid()
const now = new Date()
return parseValibot(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: [],
})
}
async function fetchRemote(client: CharactersRemoteClient, params: { all?: boolean }, options?: CharacterServiceOptions): Promise<Character[]> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.characters.$get({
query: { all: String(params.all ?? false) },
}, requestOptions(options))
if (!res.ok)
throw new Error('Failed to fetch characters')
const data = await res.json()
options?.abortSignal?.throwIfAborted()
return data.map((item: unknown) => parse(item))
}
async function fetchRemoteById(client: CharactersRemoteClient, id: string, options?: CharacterServiceOptions): Promise<Character> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.characters[':id'].$get({ param: { id } }, requestOptions(options))
if (!res.ok)
throw new Error('Failed to fetch character')
const data = await res.json()
options?.abortSignal?.throwIfAborted()
return parse(data)
}
async function createRemote(client: CharactersRemoteClient, payload: CreateCharacterPayload, options?: CharacterServiceOptions): Promise<Character> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.characters.$post({ json: payload }, requestOptions(options))
if (!res.ok)
throw new Error('Failed to create character')
const data = await res.json()
options?.abortSignal?.throwIfAborted()
return parse(data)
}
async function updateRemote(client: CharactersRemoteClient, id: string, payload: UpdateCharacterPayload, options?: CharacterServiceOptions): Promise<Character> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.characters[':id'].$patch({
param: { id },
json: payload,
}, requestOptions(options))
if (!res.ok)
throw new Error('Failed to update character')
const data = await res.json()
options?.abortSignal?.throwIfAborted()
return parse(data)
}
async function removeRemote(client: CharactersRemoteClient, id: string, options?: CharacterServiceOptions): Promise<void> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.characters[':id'].$delete({ param: { id } }, requestOptions(options))
if (!res.ok)
throw new Error('Failed to remove character')
options?.abortSignal?.throwIfAborted()
}
async function likeRemote(client: CharactersRemoteClient, id: string, options?: CharacterServiceOptions): Promise<Character> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.characters[':id'].like.$post({ param: { id } }, requestOptions(options))
if (!res.ok)
throw new Error('Failed to like character')
const data = await res.json()
options?.abortSignal?.throwIfAborted()
return parse(data)
}
async function bookmarkRemote(client: CharactersRemoteClient, id: string, options?: CharacterServiceOptions): Promise<Character> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.characters[':id'].bookmark.$post({ param: { id } }, requestOptions(options))
if (!res.ok)
throw new Error('Failed to bookmark character')
const data = await res.json()
options?.abortSignal?.throwIfAborted()
return parse(data)
}
return {
buildLocal,
fetchRemote,
fetchRemoteById,
createRemote,
updateRemote,
removeRemote,
likeRemote,
bookmarkRemote,
}
}
export const charactersService = createCharactersService()
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest'
import { providerOpenAICompatible } from '../libs/providers/providers/openai-compatible'
import { inferenceServiceProvidersService } from './inference-service-providers'
/**
* @example
* describe('services inference-service-providers', () => {})
*/
describe('services inference-service-providers', () => {
/**
* @example
* const provider = inferenceServiceProvidersService.buildLocal('openai-compatible')
*/
it('builds a local provider from a known definition', () => {
const provider = inferenceServiceProvidersService.buildLocal(providerOpenAICompatible.id, {})
expect(provider.id).toBeDefined()
expect(provider.definitionId).toBe(providerOpenAICompatible.id)
expect(provider.name).toBe('OpenAI Compatible')
expect(provider.config).toEqual({})
expect(provider.validated).toBe(false)
expect(provider.validationBypassed).toBe(false)
})
/**
* @example
* expect(() => inferenceServiceProvidersService.buildLocal('missing')).toThrow()
*/
it('rejects unknown provider definitions', () => {
expect(() => inferenceServiceProvidersService.buildLocal('missing-definition', {})).toThrow('Provider definition with id "missing-definition" not found.')
})
/**
* @example
* await inferenceServiceProvidersService.fetchRemote(client)
*/
it('fetches remote providers and indexes them by id', async () => {
const client = {
api: {
v1: {
providers: {
'$get': vi.fn(async () => ({
ok: true,
json: async () => [{
id: 'provider-1',
definitionId: providerOpenAICompatible.id,
name: 'OpenAI Compatible',
config: { baseUrl: 'https://example.com/v1/' },
validated: true,
validationBypassed: false,
}],
})),
'$post': vi.fn(async () => ({
ok: true,
json: async () => ({
id: 'provider-1',
definitionId: providerOpenAICompatible.id,
name: 'OpenAI Compatible',
config: {},
validated: false,
validationBypassed: false,
}),
})),
':id': {
$delete: vi.fn(async () => ({ ok: true })),
$patch: vi.fn(async () => ({
ok: true,
json: async () => ({
id: 'provider-1',
definitionId: providerOpenAICompatible.id,
name: 'OpenAI Compatible',
config: {},
validated: false,
validationBypassed: false,
}),
})),
},
},
},
},
}
await expect(inferenceServiceProvidersService.fetchRemote(client)).resolves.toEqual({
'provider-1': expect.objectContaining({
config: { baseUrl: 'https://example.com/v1/' },
id: 'provider-1',
validated: true,
}),
})
})
/**
* @example
* await expect(inferenceServiceProvidersService.fetchRemote(client, { abortSignal })).rejects.toThrow()
*/
it('throws before remote work when aborted', async () => {
const controller = new AbortController()
controller.abort()
const client = {
api: {
v1: {
providers: {
'$get': vi.fn(),
'$post': vi.fn(),
':id': {
$delete: vi.fn(),
$patch: vi.fn(),
},
},
},
},
}
await expect(inferenceServiceProvidersService.fetchRemote(client, { abortSignal: controller.signal })).rejects.toThrow()
})
})
@@ -0,0 +1,222 @@
import type { InferenceServiceProvider, InferenceServiceProviders } from '../models/inference-service-providers'
import { nanoid } from 'nanoid'
import { getDefinedProvider, listProviders } from '../libs/providers/providers'
interface RequestOptions {
init: { signal: AbortSignal }
}
interface RemoteResponse<T> {
json: () => Promise<T>
ok: boolean
}
/**
* Remote inference provider API surface required by the provider service.
*/
export interface InferenceServiceProvidersRemoteClient {
api: {
v1: {
providers: {
'$get': (params?: undefined, options?: RequestOptions) => Promise<RemoteResponse<unknown[]>>
'$post': (params: { json: InferenceServiceProvider }, options?: RequestOptions) => Promise<RemoteResponse<unknown>>
':id': {
$delete: (params: { param: { id: string } }, options?: RequestOptions) => Promise<{ ok: boolean }>
$patch: (params: {
json: {
config: Record<string, unknown>
validated: boolean
validationBypassed: boolean
}
param: { id: string }
}, options?: RequestOptions) => Promise<RemoteResponse<unknown>>
}
}
}
}
}
/**
* Options shared by inference service provider service operations.
*/
export interface InferenceServiceProviderServiceOptions {
/**
* Cancels the operation before or after remote IO.
*/
abortSignal?: AbortSignal
}
/**
* Provider config validation state to persist remotely.
*/
export interface PatchConfigParams {
/** Whether the provider config has passed validation. */
validated: boolean
/** Whether validation was intentionally bypassed by the user. */
validationBypassed: boolean
}
/**
* Inference service provider domain operations used by controller stores.
*/
export interface InferenceServiceProvidersService {
/** Gets one built-in provider definition. */
getDefinition: (definitionId: string) => ReturnType<typeof getDefinedProvider>
/** Lists built-in provider definitions. */
listDefinitions: typeof listProviders
/** Builds an optimistic local provider config. */
buildLocal: (definitionId: string, initialConfig?: Record<string, unknown>) => InferenceServiceProvider
/** Fetches and indexes remote provider configs. */
fetchRemote: (client: InferenceServiceProvidersRemoteClient, options?: InferenceServiceProviderServiceOptions) => Promise<InferenceServiceProviders>
/** Creates and normalizes one remote provider config. */
createRemote: (client: InferenceServiceProvidersRemoteClient, provider: InferenceServiceProvider, options?: InferenceServiceProviderServiceOptions) => Promise<InferenceServiceProvider>
/** Deletes one remote provider config. */
deleteRemote: (client: InferenceServiceProvidersRemoteClient, providerId: string, options?: InferenceServiceProviderServiceOptions) => Promise<void>
/** Patches and normalizes one remote provider config. */
patchConfigRemote: (
client: InferenceServiceProvidersRemoteClient,
providerId: string,
config: Record<string, unknown>,
params: PatchConfigParams,
options?: InferenceServiceProviderServiceOptions,
) => Promise<InferenceServiceProvider>
}
/**
* Creates the inference service provider facade consumed by controller stores.
*
* Use when:
* - Wiring controller stores to provider domain operations.
* - Tests need to replace the whole service surface with one mock object.
*
* Expects:
* - No runtime dependencies are required yet.
*
* Returns:
* - A stable object containing provider domain operations.
*/
export function createInferenceServiceProvidersService(): InferenceServiceProvidersService {
function requestOptions(options?: InferenceServiceProviderServiceOptions): RequestOptions | undefined {
return options?.abortSignal ? { init: { signal: options.abortSignal } } : undefined
}
function getDefinition(definitionId: string) {
return getDefinedProvider(definitionId)
}
function listDefinitions() {
return listProviders()
}
function buildLocal(definitionId: string, initialConfig: Record<string, unknown> = {}): InferenceServiceProvider {
const definition = getDefinition(definitionId)
if (!definition)
throw new Error(`Provider definition with id "${definitionId}" not found.`)
return {
id: nanoid(),
definitionId,
name: definition.name,
config: initialConfig,
validated: false,
validationBypassed: false,
}
}
function normalize(value: unknown): InferenceServiceProvider {
const item = value as InferenceServiceProvider
return {
id: item.id,
definitionId: item.definitionId,
name: item.name,
config: item.config,
validated: item.validated,
validationBypassed: item.validationBypassed,
}
}
async function fetchRemote(client: InferenceServiceProvidersRemoteClient, options?: InferenceServiceProviderServiceOptions): Promise<InferenceServiceProviders> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.providers.$get(undefined, requestOptions(options))
if (!res.ok)
throw new Error('Failed to fetch providers')
const data = await res.json() as unknown[]
options?.abortSignal?.throwIfAborted()
const providers: InferenceServiceProviders = {}
for (const item of data) {
const provider = normalize(item)
providers[provider.id] = provider
}
return providers
}
async function createRemote(client: InferenceServiceProvidersRemoteClient, provider: InferenceServiceProvider, options?: InferenceServiceProviderServiceOptions): Promise<InferenceServiceProvider> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.providers.$post({
json: {
id: provider.id,
definitionId: provider.definitionId,
name: provider.name,
config: provider.config,
validated: provider.validated,
validationBypassed: provider.validationBypassed,
},
}, requestOptions(options))
if (!res.ok)
throw new Error('Failed to add provider')
const item = await res.json()
options?.abortSignal?.throwIfAborted()
return normalize(item)
}
async function deleteRemote(client: InferenceServiceProvidersRemoteClient, providerId: string, options?: InferenceServiceProviderServiceOptions): Promise<void> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.providers[':id'].$delete({
param: { id: providerId },
}, requestOptions(options))
if (!res.ok)
throw new Error('Failed to remove provider')
options?.abortSignal?.throwIfAborted()
}
async function patchConfigRemote(
client: InferenceServiceProvidersRemoteClient,
providerId: string,
config: Record<string, unknown>,
params: PatchConfigParams,
options?: InferenceServiceProviderServiceOptions,
): Promise<InferenceServiceProvider> {
options?.abortSignal?.throwIfAborted()
const res = await client.api.v1.providers[':id'].$patch({
param: { id: providerId },
json: {
config,
validated: params.validated,
validationBypassed: params.validationBypassed,
},
}, requestOptions(options))
if (!res.ok)
throw new Error('Failed to update provider config')
const item = await res.json()
options?.abortSignal?.throwIfAborted()
return normalize(item)
}
return {
getDefinition,
listDefinitions,
buildLocal,
fetchRemote,
createRemote,
deleteRemote,
patchConfigRemote,
}
}
export const inferenceServiceProvidersService = createInferenceServiceProvidersService()
@@ -0,0 +1,174 @@
import type { CharactersModel } from '../models/characters'
import type { CharactersRemoteClient, CharactersService } from '../services/characters'
import type { Character, CreateCharacterPayload, UpdateCharacterPayload } from '../types/character'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createCharactersListQueryOptions, createCharacterStoreController } from './characters'
const character = {
id: 'character-1',
version: '1',
coverUrl: 'cover.png',
avatarUrl: undefined,
characterAvatarUrl: undefined,
coverBackgroundUrl: undefined,
creatorRole: undefined,
priceCredit: '0',
likesCount: 0,
bookmarksCount: 0,
interactionsCount: 0,
forksCount: 0,
creatorId: 'user-1',
ownerId: 'user-1',
characterId: 'airi',
createdAt: new Date('2026-05-08T00:00:00.000Z'),
updatedAt: new Date('2026-05-08T00:00:00.000Z'),
deletedAt: undefined,
capabilities: [],
avatarModels: [],
i18n: [],
prompts: [],
likes: [],
bookmarks: [],
} satisfies Character
const payload = {
character: { version: '1', coverUrl: 'cover.png', characterId: 'airi' },
capabilities: [],
avatarModels: [],
i18n: [],
prompts: [],
} satisfies CreateCharacterPayload
function createMutation<TVars, TData>(mutation: (vars: TVars) => Promise<TData>) {
return {
error: ref<Error | null>(null),
async mutateAsync(vars: TVars) {
try {
return await mutation(vars)
}
catch (error) {
this.error.value = error as Error
throw error
}
},
}
}
function setupController() {
const model: CharactersModel = {
list: vi.fn(async () => []),
saveAll: vi.fn(async () => {}),
upsert: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
}
const service: CharactersService = {
buildLocal: vi.fn(() => ({ ...character, id: 'local-character' })),
fetchRemote: vi.fn(async () => []),
fetchRemoteById: vi.fn(async () => character),
createRemote: vi.fn(async () => character),
updateRemote: vi.fn(async () => character),
removeRemote: vi.fn(async () => {}),
likeRemote: vi.fn(async () => ({ ...character, likesCount: 1 })),
bookmarkRemote: vi.fn(async () => ({ ...character, bookmarksCount: 1 })),
}
const listQuery = {
error: ref<Error | null>(null),
isLoading: ref(false),
refetch: vi.fn(async () => ({ data: [{ ...character, id: 'remote-character' }] })),
}
const controller = createCharacterStoreController({
auth: { userId: 'user-1' },
bookmarkMutation: createMutation<string, Character>(id => service.bookmarkRemote({} as CharactersRemoteClient, id)),
characters: ref<Map<string, Character>>(new Map()),
createMutation: createMutation<CreateCharacterPayload, Character>(nextPayload => service.createRemote({} as CharactersRemoteClient, nextPayload)),
likeMutation: createMutation<string, Character>(id => service.likeRemote({} as CharactersRemoteClient, id)),
listAll: ref(false),
listQuery,
model,
removeMutation: createMutation<string, void>(id => service.removeRemote({} as CharactersRemoteClient, id)),
service,
updateMutation: createMutation<{ id: string, data: UpdateCharacterPayload }, Character>(vars => service.updateRemote({} as CharactersRemoteClient, vars.id, vars.data)),
})
return { controller, listQuery, model, service }
}
/**
* @example
* describe('store characters controller', () => {})
*/
describe('store characters controller', () => {
/**
* @example
* await controller.fetchList()
*/
it('fetchList reads local characters first and then applies remote characters', async () => {
const { controller, model } = setupController()
vi.mocked(model.list).mockResolvedValueOnce([character])
await controller.fetchList()
expect(controller.characters.value.get('remote-character')).toBeDefined()
expect(controller.characters.value.get('character-1')).toBeUndefined()
expect(model.saveAll).toHaveBeenCalledWith([expect.objectContaining({ id: 'remote-character' })])
})
/**
* @example
* await controller.create(payload)
*/
it('keeps local create state and exposes mutation errors when remote create fails', async () => {
const { controller, service } = setupController()
const error = new Error('remote create failed')
vi.mocked(service.createRemote).mockRejectedValueOnce(error)
await expect(controller.create(payload)).resolves.toEqual(expect.objectContaining({ id: 'local-character' }))
expect(controller.characters.value.get('local-character')).toBeDefined()
expect(controller.mutationError.value).toBe(error)
})
/**
* @example
* await controller.update('character-1', { version: '2' })
*/
it('supports update remove like and bookmark through mutation controllers', async () => {
const { controller, model, service } = setupController()
controller.characters.value.set(character.id, character)
await controller.update(character.id, { version: '2' })
await controller.like(character.id)
await controller.bookmark(character.id)
await controller.remove(character.id)
expect(service.updateRemote).toHaveBeenCalled()
expect(service.likeRemote).toHaveBeenCalled()
expect(service.bookmarkRemote).toHaveBeenCalled()
expect(service.removeRemote).toHaveBeenCalled()
expect(model.remove).toHaveBeenCalledWith(character.id)
})
/**
* @example
* await options.query({ signal })
*/
it('passes Pinia Colada query abort signal to the character service', async () => {
const service = {
fetchRemote: vi.fn(async () => [] as Character[]),
}
const listAll = ref(true)
const controller = new AbortController()
const options = createCharactersListQueryOptions({
client: {} as CharactersRemoteClient,
listAll,
service: service as Pick<CharactersService, 'fetchRemote'>,
})
await options.query({ signal: controller.signal })
expect(service.fetchRemote).toHaveBeenCalledWith({}, { all: true }, { abortSignal: controller.signal })
})
})
+237 -248
View File
@@ -1,286 +1,222 @@
import type { Ref } from 'vue'
import type { Character, CreateCharacterPayload, UpdateCharacterPayload } from '../types/character'
import { nanoid } from 'nanoid'
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { defineStore } from 'pinia'
import { parse } from 'valibot'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { client } from '../composables/api'
import { useLocalFirstRequest } from '../composables/use-local-first'
import { charactersRepo } from '../database/repos/characters.repo'
import { CharacterWithRelationsSchema } from '../types/character'
import { charactersModel as model } from '../models/characters'
import { charactersService as service } from '../services/characters'
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: [],
})
interface StoreQuery<TData> {
error: Ref<Error | null>
isLoading: Ref<boolean>
refetch: (force?: boolean) => Promise<{ data?: TData }>
}
export const useCharacterStore = defineStore('characters', () => {
const characters = ref<Map<string, Character>>(new Map())
const auth = useAuthStore()
interface StoreMutation<TVars, TData> {
error: Ref<Error | null>
mutateAsync: (vars: TVars) => Promise<TData>
}
function setCharactersMap(target: Map<string, Character>, characters: Character[]) {
target.clear()
for (const character of characters) {
target.set(character.id, character)
}
}
export function createCharactersListQueryOptions(params: {
client: Parameters<typeof service.fetchRemote>[0]
listAll: Ref<boolean>
service: Pick<typeof service, 'fetchRemote'>
}) {
return {
key: () => ['characters', { all: params.listAll.value }],
query: async (context: { signal: AbortSignal }) => params.service.fetchRemote(params.client, { all: params.listAll.value }, { abortSignal: context.signal }),
enabled: false,
}
}
export function createCharacterStoreController(params: {
auth: { userId: string }
bookmarkMutation: StoreMutation<string, Character>
characters: Ref<Map<string, Character>>
createMutation: StoreMutation<CreateCharacterPayload, Character>
likeMutation: StoreMutation<string, Character>
listAll: Ref<boolean>
listQuery: StoreQuery<Character[]>
model: typeof model
removeMutation: StoreMutation<string, void>
service: typeof service
updateMutation: StoreMutation<{ id: string, data: UpdateCharacterPayload }, Character>
}) {
const {
auth,
bookmarkMutation,
characters,
createMutation,
likeMutation,
listAll,
listQuery,
model,
removeMutation,
service,
updateMutation,
} = params
const mutationError = computed(() =>
createMutation.error.value
?? updateMutation.error.value
?? removeMutation.error.value
?? likeMutation.error.value
?? bookmarkMutation.error.value)
async function fetchList(all: boolean = false) {
return useLocalFirstRequest({
local: async () => {
const cached = await charactersRepo.getAll()
if (cached.length > 0) {
characters.value.clear()
for (const char of cached) {
characters.value.set(char.id, char)
}
}
},
remote: async () => {
const res = await client.api.v1.characters.$get({
query: { all: String(all) },
})
if (!res.ok) {
throw new Error('Failed to fetch characters')
}
const data = await res.json()
listAll.value = all
const cached = await model.list()
if (cached.length > 0)
setCharactersMap(characters.value, cached)
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)
},
})
try {
const state = await listQuery.refetch(true)
if (state.data) {
await model.saveAll(state.data)
setCharactersMap(characters.value, state.data)
}
return state.data ?? cached
}
catch {
return cached
}
}
async function fetchById(id: string) {
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.v1.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)
const cached = characters.value.get(id) ?? (await model.list()).find(character => character.id === id)
if (cached)
characters.value.set(cached.id, cached)
characters.value.set(character.id, character)
await charactersRepo.upsert(character)
return character
},
})
try {
const remote = await service.fetchRemoteById(client, id)
characters.value.set(remote.id, remote)
await model.upsert(remote)
return remote
}
catch {
return cached
}
}
async function create(payload: CreateCharacterPayload) {
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.v1.characters.$post({
json: payload,
})
if (!res.ok) {
throw new Error('Failed to create character')
}
const data = await res.json()
const character = parse(CharacterWithRelationsSchema, data)
const localCharacter = service.buildLocal(auth.userId, payload)
characters.value.set(localCharacter.id, localCharacter)
await model.upsert(localCharacter)
// 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
},
})
try {
const remote = await createMutation.mutateAsync(payload)
characters.value.delete(localCharacter.id)
await model.remove(localCharacter.id)
characters.value.set(remote.id, remote)
await model.upsert(remote)
return remote
}
catch {
return localCharacter
}
}
async function update(id: string, payload: UpdateCharacterPayload) {
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.v1.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)
const character = characters.value.get(id)
if (!character)
return
characters.value.set(character.id, character)
await charactersRepo.upsert(character)
return character
},
})
const localCharacter = {
...character,
...(payload.version !== undefined ? { version: payload.version } : {}),
...(payload.coverUrl !== undefined ? { coverUrl: payload.coverUrl } : {}),
...(payload.characterId !== undefined ? { characterId: payload.characterId } : {}),
updatedAt: new Date(),
}
characters.value.set(localCharacter.id, localCharacter)
await model.upsert(localCharacter)
try {
const remote = await updateMutation.mutateAsync({ id, data: payload })
characters.value.set(remote.id, remote)
await model.upsert(remote)
return remote
}
catch {
return localCharacter
}
}
async function remove(id: string) {
return useLocalFirstRequest({
local: async () => {
characters.value.delete(id)
await charactersRepo.remove(id)
},
remote: async () => {
const res = await client.api.v1.characters[':id'].$delete({
param: { id },
})
if (!res.ok) {
throw new Error('Failed to remove character')
}
},
})
characters.value.delete(id)
await model.remove(id)
try {
await removeMutation.mutateAsync(id)
}
catch {
// Keep current local-first behavior: local removal is retained on remote failure.
}
}
async function like(id: string) {
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.v1.characters[':id'].like.$post({
param: { id },
})
if (!res.ok) {
throw new Error('Failed to like character')
}
const character = characters.value.get(id)
if (!character)
return
const data = await res.json()
const character = parse(CharacterWithRelationsSchema, data)
characters.value.set(character.id, character)
await charactersRepo.upsert(character)
},
})
const likes = character.likes ?? []
if (!likes.some(item => item.userId === auth.userId)) {
const localCharacter = {
...character,
likes: [...likes, { userId: auth.userId, characterId: id }],
likesCount: character.likesCount + 1,
updatedAt: new Date(),
}
characters.value.set(localCharacter.id, localCharacter)
await model.upsert(localCharacter)
}
try {
const remote = await likeMutation.mutateAsync(id)
characters.value.set(remote.id, remote)
await model.upsert(remote)
}
catch {
// Keep local-first optimistic state.
}
}
async function bookmark(id: string) {
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.v1.characters[':id'].bookmark.$post({
param: { id },
})
if (!res.ok) {
throw new Error('Failed to bookmark character')
}
const character = characters.value.get(id)
if (!character)
return
const data = await res.json()
const character = parse(CharacterWithRelationsSchema, data)
characters.value.set(character.id, character)
await charactersRepo.upsert(character)
},
})
const bookmarks = character.bookmarks ?? []
if (!bookmarks.some(item => item.userId === auth.userId)) {
const localCharacter = {
...character,
bookmarks: [...bookmarks, { userId: auth.userId, characterId: id }],
bookmarksCount: character.bookmarksCount + 1,
updatedAt: new Date(),
}
characters.value.set(localCharacter.id, localCharacter)
await model.upsert(localCharacter)
}
try {
const remote = await bookmarkMutation.mutateAsync(id)
characters.value.set(remote.id, remote)
await model.upsert(remote)
}
catch {
// Keep local-first optimistic state.
}
}
function getCharacter(id: string) {
@@ -289,6 +225,9 @@ export const useCharacterStore = defineStore('characters', () => {
return {
characters,
isLoading: computed(() => listQuery.isLoading.value),
error: computed(() => listQuery.error.value),
mutationError,
fetchList,
fetchById,
@@ -299,4 +238,54 @@ export const useCharacterStore = defineStore('characters', () => {
bookmark,
getCharacter,
}
}
export const useCharacterStore = defineStore('characters', () => {
const characters = ref<Map<string, Character>>(new Map())
const listAll = ref(false)
const auth = useAuthStore()
const queryCache = useQueryCache()
const listQuery = useQuery(createCharactersListQueryOptions({ client, listAll, service }))
const createMutation = useMutation({
mutation: async (payload: CreateCharacterPayload) => service.createRemote(client, payload),
async onSettled() {
await queryCache.invalidateQueries({ key: ['characters'] })
},
})
const updateMutation = useMutation({
mutation: async (payload: { id: string, data: UpdateCharacterPayload }) => service.updateRemote(client, payload.id, payload.data),
async onSettled() {
await queryCache.invalidateQueries({ key: ['characters'] })
},
})
const removeMutation = useMutation({
mutation: async (id: string) => service.removeRemote(client, id),
async onSettled() {
await queryCache.invalidateQueries({ key: ['characters'] })
},
})
const likeMutation = useMutation({
mutation: async (id: string) => service.likeRemote(client, id),
})
const bookmarkMutation = useMutation({
mutation: async (id: string) => service.bookmarkRemote(client, id),
})
return createCharacterStoreController({
auth,
bookmarkMutation,
characters,
createMutation,
likeMutation,
listAll,
listQuery,
model,
removeMutation,
service,
updateMutation,
})
})
@@ -1,59 +1,156 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { InferenceServiceProvider, InferenceServiceProviders, InferenceServiceProvidersModel } from '../models/inference-service-providers'
import type { InferenceServiceProvidersRemoteClient, InferenceServiceProvidersService, PatchConfigParams } from '../services/inference-service-providers'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { providerOpenAICompatible } from '../libs/providers/providers/openai-compatible'
import { useProviderCatalogStore } from './provider-catalog'
import { createProviderCatalogListQueryOptions, createProviderCatalogStoreController } from './provider-catalog'
vi.mock('../database/repos/providers.repo', () => ({
providersRepo: {
getAll: vi.fn(async () => ({})),
const localProvider = {
id: 'local-provider',
definitionId: providerOpenAICompatible.id,
name: 'OpenAI Compatible',
config: {},
validated: false,
validationBypassed: false,
} satisfies InferenceServiceProvider
const remoteProvider = {
id: 'real-id',
definitionId: providerOpenAICompatible.id,
name: 'OpenAI Compatible',
config: {},
validated: false,
validationBypassed: false,
} satisfies InferenceServiceProvider
function createMutation<TVars, TData>(mutation: (vars: TVars) => Promise<TData>) {
return {
error: ref<Error | null>(null),
async mutateAsync(vars: TVars) {
try {
return await mutation(vars)
}
catch (error) {
this.error.value = error as Error
throw error
}
},
}
}
function setupController() {
const model: InferenceServiceProvidersModel = {
list: 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 () => ({}) })),
},
}
const service: InferenceServiceProvidersService = {
getDefinition: vi.fn(() => providerOpenAICompatible),
listDefinitions: vi.fn(() => [providerOpenAICompatible]),
buildLocal: vi.fn(() => localProvider),
fetchRemote: vi.fn(async () => ({})),
createRemote: vi.fn(async () => remoteProvider),
deleteRemote: vi.fn(async () => {}),
patchConfigRemote: vi.fn(async () => ({ ...remoteProvider, id: 'provider-1', validated: true })),
}
const providersQuery = {
error: ref<Error | null>(null),
isLoading: ref(false),
refetch: vi.fn(async () => ({
data: {
'remote-id': { ...remoteProvider, id: 'remote-id' },
},
},
},
}))
describe('store provider-catalog', () => {
beforeEach(() => {
// creates a fresh pinia and makes it active
// so it's automatically picked up by any useStore() call
// without having to pass it to it: `useStore(pinia)`
setActivePinia(createPinia())
})),
}
const controller = createProviderCatalogStoreController({
addProviderMutation: createMutation<InferenceServiceProvider, InferenceServiceProvider>(provider => service.createRemote({} as InferenceServiceProvidersRemoteClient, provider)),
commitProviderConfigMutation: createMutation<{ providerId: string, config: Record<string, unknown>, options: PatchConfigParams }, InferenceServiceProvider>(
vars => service.patchConfigRemote({} as InferenceServiceProvidersRemoteClient, vars.providerId, vars.config, vars.options),
),
configs: ref<Record<string, InferenceServiceProvider>>({}),
model,
providersQuery,
removeProviderMutation: createMutation<string, void>(id => service.deleteRemote({} as InferenceServiceProvidersRemoteClient, id)),
service,
})
it('add', () => {
const store = useProviderCatalogStore()
store.addProvider(providerOpenAICompatible.id)
return { controller, model, service }
}
expect(Object.values(store.configs)).toHaveLength(1)
expect(Object.values(store.configs)[0].id).toBeDefined()
expect(Object.values(store.configs)[0].definitionId).toBe(providerOpenAICompatible.id)
expect(Object.values(store.configs)[0].name).toBe('OpenAI Compatible')
expect(Object.values(store.configs)[0].config).toStrictEqual({})
/**
* @example
* describe('store provider-catalog controller', () => {})
*/
describe('store provider-catalog controller', () => {
/**
* @example
* await controller.fetchList()
*/
it('fetchList reads local configs first and then applies remote configs', async () => {
const { controller, model } = setupController()
vi.mocked(model.list).mockResolvedValueOnce({ 'local-id': localProvider })
await controller.fetchList()
expect(controller.configs.value['remote-id']).toBeDefined()
expect(controller.configs.value['local-id']).toBeUndefined()
})
it('remove', () => {
const store = useProviderCatalogStore()
store.addProvider(providerOpenAICompatible.id)
/**
* @example
* await controller.addProvider(providerOpenAICompatible.id)
*/
it('keeps local add state and exposes mutation errors when remote add fails', async () => {
const { controller, service } = setupController()
const error = new Error('remote add failed')
vi.mocked(service.createRemote).mockRejectedValueOnce(error)
const providerId = Object.keys(store.configs)[0]
store.removeProvider(providerId)
await expect(controller.addProvider(providerOpenAICompatible.id)).resolves.toEqual(localProvider)
expect(Object.values(store.configs)).toHaveLength(0)
expect(controller.configs.value[localProvider.id]).toEqual(localProvider)
expect(controller.mutationError.value).toBe(error)
})
/**
* @example
* await controller.commitProviderConfig('provider-1', {}, options)
*/
it('supports remove and config commit through mutation controllers', async () => {
const { controller, model, service } = setupController()
controller.configs.value[localProvider.id] = localProvider
await controller.commitProviderConfig(localProvider.id, { apiKey: 'sk-test' }, { validated: true, validationBypassed: false })
await controller.removeProvider(localProvider.id)
expect(service.patchConfigRemote).toHaveBeenCalled()
expect(service.deleteRemote).toHaveBeenCalled()
expect(model.remove).toHaveBeenCalledWith(localProvider.id)
})
/**
* @example
* await options.query({ signal })
*/
it('passes Pinia Colada query abort signal to provider service and model', async () => {
const service = {
fetchRemote: vi.fn(async () => ({}) as Promise<InferenceServiceProviders>),
}
const model = {
saveAll: vi.fn(async () => {}),
}
const controller = new AbortController()
const options = createProviderCatalogListQueryOptions({
client: {} as InferenceServiceProvidersRemoteClient,
model: model as Pick<InferenceServiceProvidersModel, 'saveAll'>,
service: service as Pick<InferenceServiceProvidersService, 'fetchRemote'>,
})
await options.query({ signal: controller.signal })
expect(service.fetchRemote).toHaveBeenCalledWith({}, { abortSignal: controller.signal })
expect(model.saveAll).toHaveBeenCalledWith({}, { abortSignal: controller.signal })
})
})
+163 -135
View File
@@ -1,170 +1,198 @@
import type { ProviderCatalogProvider } from '../database/repos/providers.repo'
import type { Ref } from 'vue'
import { nanoid } from 'nanoid'
import type { InferenceServiceProvider } from '../models/inference-service-providers'
import type { PatchConfigParams } from '../services/inference-service-providers'
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { client } from '../composables/api'
import { useLocalFirstRequest } from '../composables/use-local-first'
import { providersRepo } from '../database/repos/providers.repo'
import { getDefinedProvider, listProviders } from '../libs/providers/providers'
import { inferenceServiceProvidersModel as model } from '../models/inference-service-providers'
import { inferenceServiceProvidersService as service } from '../services/inference-service-providers'
export const useProviderCatalogStore = defineStore('provider-catalog', () => {
const defs = computed(() => listProviders())
const configs = ref<Record<string, ProviderCatalogProvider>>({})
interface StoreQuery<TData> {
error: Ref<Error | null>
isLoading: Ref<boolean>
refetch: (force?: boolean) => Promise<{ data?: TData }>
}
interface StoreMutation<TVars, TData> {
error: Ref<Error | null>
mutateAsync: (vars: TVars) => Promise<TData>
}
export function createProviderCatalogListQueryOptions(params: {
client: Parameters<typeof service.fetchRemote>[0]
model: Pick<typeof model, 'saveAll'>
service: Pick<typeof service, 'fetchRemote'>
}) {
return {
key: ['inference-service-providers'],
query: async (context: { signal: AbortSignal }) => {
const remote = await params.service.fetchRemote(params.client, { abortSignal: context.signal })
await params.model.saveAll(remote, { abortSignal: context.signal })
return remote
},
enabled: false,
}
}
export function createProviderCatalogStoreController(params: {
addProviderMutation: StoreMutation<InferenceServiceProvider, InferenceServiceProvider>
commitProviderConfigMutation: StoreMutation<{ providerId: string, config: Record<string, unknown>, options: PatchConfigParams }, InferenceServiceProvider>
configs: Ref<Record<string, InferenceServiceProvider>>
model: typeof model
providersQuery: StoreQuery<Record<string, InferenceServiceProvider>>
removeProviderMutation: StoreMutation<string, void>
service: typeof service
}) {
const {
addProviderMutation,
commitProviderConfigMutation,
configs,
model,
providersQuery,
removeProviderMutation,
service,
} = params
const defs = computed(() => service.listDefinitions())
const mutationError = computed(() =>
addProviderMutation.error.value
?? removeProviderMutation.error.value
?? commitProviderConfigMutation.error.value)
async function fetchList() {
return useLocalFirstRequest({
local: async () => {
const cached = await providersRepo.getAll()
if (Object.keys(cached).length > 0) {
configs.value = cached
}
},
remote: async () => {
const res = await client.api.v1.providers.$get()
if (!res.ok) {
throw new Error('Failed to fetch providers')
}
const data = await res.json()
const cached = await model.list()
if (Object.keys(cached).length > 0)
configs.value = cached
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)
},
})
try {
const state = await providersQuery.refetch(true)
if (state.data)
configs.value = state.data
return state.data ?? cached
}
catch {
return cached
}
}
async function addProvider(definitionId: string, initialConfig: Record<string, any> = {}) {
const definition = getDefinedProvider(definitionId)
if (!definition) {
throw new Error(`Provider definition with id "${definitionId}" not found.`)
async function addProvider(definitionId: string, initialConfig: Record<string, unknown> = {}) {
const provider = service.buildLocal(definitionId, initialConfig)
configs.value[provider.id] = provider
await model.upsert(provider)
try {
const remote = await addProviderMutation.mutateAsync(provider)
delete configs.value[provider.id]
await model.remove(provider.id)
configs.value[remote.id] = remote
await model.upsert(remote)
return remote
}
const id = nanoid()
const provider: ProviderCatalogProvider = {
id,
definitionId,
name: definition.name,
config: initialConfig,
validated: false,
validationBypassed: false,
catch {
return provider
}
return useLocalFirstRequest<ProviderCatalogProvider>({
local: async () => {
configs.value[id] = provider
await providersRepo.upsert(provider)
return provider
},
remote: async () => {
const res = await client.api.v1.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')
}
const item = await res.json() as ProviderCatalogProvider
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) {
if (!configs.value[providerId]) {
if (!configs.value[providerId])
return
}
return useLocalFirstRequest({
local: async () => {
delete configs.value[providerId]
await providersRepo.remove(providerId)
},
remote: async () => {
const res = await client.api.v1.providers[':id'].$delete({
param: { id: providerId },
})
if (!res.ok) {
throw new Error('Failed to remove provider')
}
},
})
delete configs.value[providerId]
await model.remove(providerId)
try {
await removeProviderMutation.mutateAsync(providerId)
}
catch {
// Keep current local-first behavior: local removal is retained on remote failure.
}
}
async function commitProviderConfig(providerId: string, newConfig: Record<string, any>, options: { validated: boolean, validationBypassed: boolean }) {
async function commitProviderConfig(providerId: string, newConfig: Record<string, unknown>, options: PatchConfigParams) {
const provider = configs.value[providerId]
if (!provider) {
if (!provider)
return
}
return useLocalFirstRequest<ProviderCatalogProvider>({
local: async () => {
provider.config = { ...newConfig }
provider.validated = options.validated
provider.validationBypassed = options.validationBypassed
await providersRepo.upsert(provider)
return provider
},
remote: async () => {
const res = await client.api.v1.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() as ProviderCatalogProvider
// Sync with server response just in case
provider.config = { ...item.config }
provider.validated = item.validated
provider.validationBypassed = item.validationBypassed
await providersRepo.upsert(provider)
return provider
},
})
const localProvider = {
...provider,
config: { ...newConfig },
validated: options.validated,
validationBypassed: options.validationBypassed,
}
configs.value[providerId] = localProvider
await model.upsert(localProvider)
try {
const remote = await commitProviderConfigMutation.mutateAsync({ providerId, config: newConfig, options })
configs.value[remote.id] = remote
await model.upsert(remote)
return remote
}
catch {
return localProvider
}
}
return {
configs,
defs,
getDefinedProvider,
getDefinedProvider: service.getDefinition,
isLoading: computed(() => providersQuery.isLoading.value),
error: computed(() => providersQuery.error.value),
mutationError,
fetchList,
addProvider,
removeProvider,
commitProviderConfig,
}
}
export const useProviderCatalogStore = defineStore('provider-catalog', () => {
const queryCache = useQueryCache()
const configs = ref<Record<string, InferenceServiceProvider>>({})
const providersQuery = useQuery(createProviderCatalogListQueryOptions({
client,
model,
service,
}))
const addProviderMutation = useMutation({
mutation: async (provider: InferenceServiceProvider) => service.createRemote(client, provider),
async onSettled() {
await queryCache.invalidateQueries({ key: ['inference-service-providers'] })
},
})
const removeProviderMutation = useMutation({
mutation: async (providerId: string) => service.deleteRemote(client, providerId),
async onSettled() {
await queryCache.invalidateQueries({ key: ['inference-service-providers'] })
},
})
const commitProviderConfigMutation = useMutation({
mutation: async (payload: {
providerId: string
config: Record<string, unknown>
options: PatchConfigParams
}) => service.patchConfigRemote(client, payload.providerId, payload.config, payload.options),
async onSettled() {
await queryCache.invalidateQueries({ key: ['inference-service-providers'] })
},
})
return createProviderCatalogStoreController({
addProviderMutation,
commitProviderConfigMutation,
configs,
model,
providersQuery,
removeProviderMutation,
service,
})
})
+23
View File
@@ -90,6 +90,9 @@ catalogs:
'@nekopaw/tempora':
specifier: 0.4.0-alpha.1
version: 0.4.0-alpha.1
'@pinia/colada':
specifier: ^1.2.1
version: 1.2.1
'@pinia/testing':
specifier: ^1.0.3
version: 1.0.3
@@ -1177,6 +1180,9 @@ importers:
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@pinia/colada':
specifier: 'catalog:'
version: 1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@proj-airi/audio':
specifier: workspace:^
version: link:../../packages/audio
@@ -1643,6 +1649,9 @@ importers:
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@pinia/colada':
specifier: 'catalog:'
version: 1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@proj-airi/audio':
specifier: workspace:^
version: link:../../packages/audio
@@ -3122,6 +3131,9 @@ importers:
'@opentelemetry/sdk-trace-base':
specifier: ^2.7.0
version: 2.7.0(@opentelemetry/api@1.9.1)
'@pinia/colada':
specifier: 'catalog:'
version: 1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@proj-airi/audio':
specifier: workspace:^
version: link:../audio
@@ -8082,6 +8094,12 @@ packages:
peerDependencies:
pinia: '>=3.0.4'
'@pinia/colada@1.2.1':
resolution: {integrity: sha512-bld1LhCtct0TdYYyy8tEHFEeW0AZGRfdCMOLwdTD/Usqg7aGvDLj+GKsl9QcK4ZrFAL2HulbhLEHTy9ESZs12Q==}
peerDependencies:
pinia: ^2.2.6 || ^3.0.0
vue: ^3.5.17
'@pixi/app@6.5.10':
resolution: {integrity: sha512-VsNHLajZ5Dbc/Zrj7iWmIl3eu6Fec+afjW/NXXezD8Sp3nTDF0bv5F+GDgN/zSc2gqIvPHyundImT7hQGBDghg==}
peerDependencies:
@@ -22171,6 +22189,11 @@ snapshots:
dependencies:
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
'@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
'@pixi/app@6.5.10(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))':
dependencies:
'@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))
+1
View File
@@ -56,6 +56,7 @@ catalog:
'@moeru/std': 0.1.0-beta.17
'@napi-rs/image': ^1.12.0
'@nekopaw/tempora': 0.4.0-alpha.1
'@pinia/colada': ^1.2.1
'@pinia/testing': ^1.0.3
'@proj-airi/drizzle-duckdb-wasm': ^0.5.0
'@proj-airi/iconify-meteocons': ^0.1.5