## Summary Extract runtime-neutral provider definitions from `@proj-airi/stage-ui` into a new `@proj-airi/provider-inference` package that runs in both Node.js and browser without Vue/Pinia dependencies. ## What changed - **New package** `packages/provider-inference/` — owns `ProviderDefinition`, `ProviderRegistry`, validators, and all provider modules (cloud + local) - **Provider modules relocated** — ~45 providers moved from `stage-ui/src/libs/providers/providers/` to `provider-inference/src/providers/cloud/` and `provider-inference/src/providers/local/` - **Type decoupling** — `ProviderContext.t` changed from `ComposerTranslation` (Vue) to generic `ProviderTranslator`, removing the Vue dependency from core types - **stage-ui types slimmed** — `types.ts` now re-exports from `provider-inference` and only adds the Vue-specific `ProviderViews` extension - **validators/run.ts** — becomes a pass-through re-export - **Registry** — portable `createProviderRegistry()` with deterministic ordering and duplicate-id detection ## Verification ```bash pnpm -F @proj-airi/provider-inference typecheck pnpm -F @proj-airi/provider-inference test:node pnpm -F @proj-airi/provider-inference test:browser pnpm -F @proj-airi/provider-inference build pnpm -F @proj-airi/stage-ui typecheck pnpm -F @proj-airi/stage-web typecheck pnpm -F @proj-airi/stage-tamagotchi typecheck pnpm lint ``` ## Why Provider definitions were tightly coupled to Vue (`ComposerTranslation`, `Component`), preventing reuse in non-Vue runtimes (Node.js services, tests, future Electron backend). This extraction creates a clean boundary: runtime-neutral definitions live in `provider-inference`, while Vue/Pinia-specific concerns stay in `stage-ui`.
226 lines
8.3 KiB
TypeScript
226 lines
8.3 KiB
TypeScript
import type { ProviderDefinition, VoiceInfo } from '../../../types'
|
|
import type { VoicevoxSynthesisParameters } from './engine'
|
|
|
|
import { errorMessageFrom } from '@moeru/std'
|
|
import { z } from 'zod'
|
|
|
|
import { defineProvider } from '../../registry'
|
|
import { fetchEngineVersion, fetchSpeakers, synthesizeSpeech } from './engine'
|
|
|
|
/**
|
|
* How long the reachability check waits before it calls the engine unreachable.
|
|
*
|
|
* A local engine answers `/version` at once after it starts. A longer wait only
|
|
* delays the settings page.
|
|
*/
|
|
const REACHABILITY_TIMEOUT_MS = 5_000
|
|
|
|
/**
|
|
* How often the reachability check runs again. Ollama and LM Studio declare the
|
|
* same interval.
|
|
*
|
|
* No code starts that loop. `startPeriodicRuntimeValidation` reads the schedule,
|
|
* only `refreshListedProviderValidation` calls it, and only
|
|
* `resetProviderSettings` calls that.
|
|
*
|
|
* The provider therefore reaches an engine that starts later only when the user
|
|
* reopens the settings page, or edits the Base URL.
|
|
*/
|
|
const REACHABILITY_INTERVAL_MS = 15_000
|
|
|
|
/**
|
|
* The engine has no model concept. The speech module treats an empty model
|
|
* selection as unconfigured, so the catalogue publishes one entry with a stable
|
|
* id. The user gets no choice between alternatives that do not exist.
|
|
*/
|
|
const SYNTHETIC_MODEL_ID = 'default'
|
|
|
|
/**
|
|
* `generateSpeech` builds a URL from this before it calls the injected `fetch`.
|
|
* The adapter ignores the URL, so the value only has to parse.
|
|
*/
|
|
const SENTINEL_BASE_URL = 'http://voicevox-family.invalid/v1/'
|
|
|
|
const voicevoxVoiceSettingsSchema = z.object({
|
|
intonation: z.number().default(1),
|
|
pitch: z.number().default(0),
|
|
speed: z.number().default(1),
|
|
volume: z.number().default(1),
|
|
})
|
|
|
|
export type VoicevoxFamilyConfig = z.input<ReturnType<typeof createVoicevoxConfigSchema>>
|
|
|
|
export interface VoicevoxFamilyProviderOptions<TId extends string = string> {
|
|
/** Prefilled Base URL, and the address the validator names when the field is empty. */
|
|
defaultBaseUrl: string
|
|
/** Fallback description, shown when the locale has no entry. */
|
|
description: string
|
|
id: TId
|
|
/** Fallback name, shown when the locale has no entry. */
|
|
name: string
|
|
}
|
|
|
|
/**
|
|
* Builds one catalogue entry for an engine that implements the VOICEVOX HTTP API.
|
|
*
|
|
* `createProvider` returns an injected `fetch`, so synthesis never sends the
|
|
* OpenAI-shaped request that `generateSpeech` builds. {@link synthesizeSpeech}
|
|
* makes the two engine requests instead.
|
|
*/
|
|
export function defineVoicevoxFamilyProvider<const TId extends string>(
|
|
options: VoicevoxFamilyProviderOptions<TId>,
|
|
): ProviderDefinition<VoicevoxFamilyConfig, TId> {
|
|
const configSchema = createVoicevoxConfigSchema(options.defaultBaseUrl)
|
|
|
|
return defineProvider<VoicevoxFamilyConfig, TId>({
|
|
createProvider(config) {
|
|
return {
|
|
speech: () => ({
|
|
baseURL: SENTINEL_BASE_URL,
|
|
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
const { input, voice } = readSpeechRequest(init)
|
|
const wav = await synthesizeSpeech(
|
|
config.baseUrl ?? options.defaultBaseUrl,
|
|
{
|
|
parameters: config.voiceSettings as undefined | VoicevoxSynthesisParameters,
|
|
styleId: voice,
|
|
text: input,
|
|
},
|
|
{ signal: init?.signal ?? undefined },
|
|
)
|
|
|
|
return new Response(wav, { headers: { 'Content-Type': 'audio/wav' }, status: 200 })
|
|
},
|
|
model: SYNTHETIC_MODEL_ID,
|
|
}),
|
|
}
|
|
},
|
|
createProviderConfig: () => configSchema,
|
|
description: options.description,
|
|
descriptionLocalize: ({ t }) => t(`settings.pages.providers.provider.${options.id}.description`),
|
|
extraMethods: {
|
|
listModels: async () => [{
|
|
contextLength: 0,
|
|
deprecated: false,
|
|
description: '',
|
|
id: SYNTHETIC_MODEL_ID,
|
|
name: options.name,
|
|
provider: options.id,
|
|
}],
|
|
|
|
listVoices: async (config) => {
|
|
const speakers = await fetchSpeakers(config.baseUrl?.trim() ?? '')
|
|
return speakers.flatMap(speaker => (speaker.styles ?? []).map(style => toVoiceInfo(options.id, speaker.name, style)))
|
|
},
|
|
},
|
|
icon: 'i-lobe-icons:speaker',
|
|
id: options.id,
|
|
name: options.name,
|
|
|
|
nameLocalize: ({ t }) => t(`settings.pages.providers.provider.${options.id}.title`),
|
|
|
|
tasks: ['text-to-speech'],
|
|
|
|
validationRequiredWhen: config => Boolean(config.baseUrl?.trim()),
|
|
|
|
validators: {
|
|
validateConfig: [
|
|
({ t }) => ({
|
|
id: `${options.id}:check-config`,
|
|
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
|
|
validator: async (config) => {
|
|
const reason = absoluteUrlError(config.baseUrl?.trim() ?? '', options.defaultBaseUrl)
|
|
if (reason)
|
|
return { errors: [{ error: new Error(reason) }], reason, reasonKey: '', valid: false }
|
|
|
|
return { errors: [], reason: '', reasonKey: '', valid: true }
|
|
},
|
|
}),
|
|
],
|
|
// The reachability probe belongs here, not in `validateConfig`.
|
|
// `getProviderValidationIntervalMs` reads schedules only from
|
|
// `validateProvider`. A schedule declared on `validateConfig` never runs.
|
|
validateProvider: [
|
|
({ t }) => ({
|
|
id: `${options.id}:check-reachability`,
|
|
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-connectivity.title'),
|
|
schedule: {
|
|
intervalMs: REACHABILITY_INTERVAL_MS,
|
|
mode: 'interval',
|
|
},
|
|
validator: async (config) => {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), REACHABILITY_TIMEOUT_MS)
|
|
try {
|
|
await fetchEngineVersion(config.baseUrl?.trim() ?? '', { signal: controller.signal })
|
|
return { errors: [], reason: '', reasonKey: '', valid: true }
|
|
}
|
|
catch (error) {
|
|
const reason = `Cannot reach the speech engine: ${errorMessageFrom(error) ?? 'Unknown error'}\n\nMake sure the engine is running and that the Base URL matches its port.`
|
|
return { errors: [{ error }], reason, reasonKey: '', valid: false }
|
|
}
|
|
finally {
|
|
clearTimeout(timeout)
|
|
}
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
})
|
|
}
|
|
|
|
function absoluteUrlError(baseUrl: string, defaultBaseUrl: string) {
|
|
if (!baseUrl)
|
|
return `Base URL is required. Default to ${defaultBaseUrl} for this engine.`
|
|
|
|
try {
|
|
const url = new URL(baseUrl)
|
|
// Check the scheme as well as the host. `ftp://engine/` parses and has a
|
|
// host, so a host-only check accepts an address `fetch` cannot use, and then
|
|
// asks the user for a scheme they already gave.
|
|
if (!url.host || (url.protocol !== 'http:' && url.protocol !== 'https:'))
|
|
return 'Base URL is not absolute. Try to include a scheme (http:// or https://).'
|
|
}
|
|
catch {
|
|
return 'Base URL is not absolute. Try to include a scheme (http:// or https://).'
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function createVoicevoxConfigSchema(defaultBaseUrl: string) {
|
|
return z.object({
|
|
baseUrl: z.string().default(defaultBaseUrl),
|
|
voiceSettings: voicevoxVoiceSettingsSchema.default({ intonation: 1, pitch: 0, speed: 1, volume: 1 }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Reads the segment text and the style id out of the OpenAI-shaped body that
|
|
* `generateSpeech` builds.
|
|
*
|
|
* `requestBody` in `@xsai/shared` passes that object through `objCamelToSnake`.
|
|
* Only single-word keys survive unchanged, and `input` and `voice` are two of
|
|
* them. A key of more than one word arrives renamed. The synthesis parameters
|
|
* therefore come from the provider configuration, not from this body.
|
|
*/
|
|
function readSpeechRequest(init: RequestInit | undefined): { input: string, voice: string } {
|
|
if (!init?.body || typeof init.body !== 'string')
|
|
throw new Error('Invalid speech request body')
|
|
|
|
const body = JSON.parse(init.body) as { input?: string, voice?: string }
|
|
if (!body.voice)
|
|
throw new Error('No voice selected. Pick a character in the speech settings.')
|
|
|
|
return { input: body.input ?? '', voice: body.voice }
|
|
}
|
|
|
|
function toVoiceInfo(providerId: string, speakerName: string, style: { id: number, name: string }): VoiceInfo {
|
|
return {
|
|
id: String(style.id),
|
|
languages: [{ code: 'ja', title: 'Japanese' }],
|
|
name: `${speakerName} / ${style.name}`,
|
|
provider: providerId,
|
|
}
|
|
}
|