diff --git a/README.md b/README.md index 5a7ebb7..2cbac30 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mio-ai -[![Mio](./docs/assets/91986900_p7.jpg)](https://www.pixiv.net/en/artworks/91986900) +[![Mio](./docs/assets/mio-ai.png)](https://www.pixiv.net/en/artworks/91986900) Many-in-one AI client. @@ -21,5 +21,5 @@ Many-in-one AI client. - [ ] fine-tunes - [ ] moderations * ElevenLabs - - [ ] TTS (stream) - - [ ] get voices + - [X] TTS (stream) + - [X] get voices diff --git a/docs/assets/91986900_p7.jpg b/docs/assets/91986900_p7.jpg deleted file mode 100644 index e26274d..0000000 Binary files a/docs/assets/91986900_p7.jpg and /dev/null differ diff --git a/docs/assets/mio-ai.png b/docs/assets/mio-ai.png new file mode 100644 index 0000000..3ee1dc1 Binary files /dev/null and b/docs/assets/mio-ai.png differ diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..bf1e50b --- /dev/null +++ b/src/common.ts @@ -0,0 +1,16 @@ +export type DataEventCallback = (data: D) => void; + +export type ErrorEventCallback = (event: Error) => void; + +export interface PlatformEventEmitter extends NodeJS.EventEmitter { + on(event: 'data', callback: DataEventCallback): this; + on(event: 'end', callback: () => void): this; + on(event: 'error', callback: ErrorEventCallback): this; +} + +export class PlatformApiError extends Error { + constructor(message: string, readonly response: Response) { + super(message); + this.name = 'PlatformApiError'; + } +} diff --git a/src/index.ts b/src/index.ts index 4b12a7e..092d1f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,22 @@ import * as OpenAiImpl from './platforms/openai'; +import * as ElevenLabsImpl from './platforms/elevenlabs'; -const SUPPORTED_PLATFORMS = { OpenAi: OpenAiImpl } as const; +const SUPPORTED_PLATFORMS = { + OpenAi: OpenAiImpl, + ElevenLabs: ElevenLabsImpl, +} as const; export * as OpenAi from './platforms/openai'; -export type PlatformConfig = OpenAiImpl.PlatformConfig; -export type PlatformEventEmitter = OpenAiImpl.PlatformEventEmitter; +export * as ElevenLabs from './platforms/elevenlabs'; + +export type PlatformConfig = ( + OpenAiImpl.PlatformConfig + | ElevenLabsImpl.PlatformConfig +) +export type PlatformEventEmitter = ( + OpenAiImpl.PlatformEventEmitter + | ElevenLabsImpl.PlatformEventEmitter +); export const createAiClient = (configParams: PlatformConfig): PlatformEventEmitter => { const { diff --git a/src/packages/request.ts b/src/packages/request.ts new file mode 100644 index 0000000..90e1bcc --- /dev/null +++ b/src/packages/request.ts @@ -0,0 +1,28 @@ +export type DoFetchBody = BodyInit | Record + +export type DoFetch = ( + method: string, + path: string, + body?: DoFetchBody +) => Promise; + +export type ConsumeStream = ( + response: Response, +) => Promise; + +export const processRequest = (body: DoFetchBody, requestHeaders: Record) => { + if ( + body instanceof FormData + || body instanceof URLSearchParams + ) { + return { body }; + } + + return { + body: JSON.stringify(body), + headers: { + ...requestHeaders, + 'Content-Type': 'application/json', + }, + }; +}; diff --git a/src/platforms/elevenlabs/common.ts b/src/platforms/elevenlabs/common.ts index 83546a3..ca93713 100644 --- a/src/platforms/elevenlabs/common.ts +++ b/src/platforms/elevenlabs/common.ts @@ -1,4 +1,4 @@ -export const enum ApiVersion { +export enum ApiVersion { V1 = 'v1', } @@ -7,3 +7,5 @@ export interface Configuration { apiVersion: ApiVersion; baseUrl?: string; } + +export const DEFAULT_BASE_URL = 'https://api.elevenlabs.io' as const; diff --git a/src/platforms/elevenlabs/events.ts b/src/platforms/elevenlabs/events.ts index e69de29..10ab9d1 100644 --- a/src/platforms/elevenlabs/events.ts +++ b/src/platforms/elevenlabs/events.ts @@ -0,0 +1,72 @@ +import { PassThrough } from 'stream'; +import { EventEmitter } from 'events'; +import fetchPonyfill from 'fetch-ponyfill'; +import { DoFetchBody, processRequest } from '../../packages/request'; +import * as AllPlatformsCommon from '../../common'; +import { Configuration, DEFAULT_BASE_URL } from './common'; +import { createTextToSpeech, CreateTextToSpeechParams } from './features/tts'; +import { getVoices } from './features/voice'; + +export interface PlatformEventEmitter extends AllPlatformsCommon.PlatformEventEmitter { + getVoices(): void; + createTextToSpeech(params: CreateTextToSpeechParams): void; +} + +export class PlatformEventEmitterImpl extends EventEmitter implements PlatformEventEmitter { + readonly getVoices: PlatformEventEmitter['getVoices']; + + readonly createTextToSpeech: PlatformEventEmitter['createTextToSpeech']; + + constructor(configParams: Configuration) { + super(); + const platformHeaders: Record = { + 'XI-API-Key': `Bearer ${configParams.apiKey}`, + }; + + const { fetch: fetchInstance } = fetchPonyfill(); + const doFetch = (method: string, path: string, body?: DoFetchBody) => { + let finalBody: BodyInit | undefined; + let finalHeaders = { + ...platformHeaders, + }; + if (body) { + const finalRequest = processRequest(body, finalHeaders); + finalBody = finalRequest.body; + if (finalRequest.headers) { + finalHeaders = finalRequest.headers; + } + } + + const theFetchParams: Record = { + method, + headers: finalHeaders, + }; + + if (finalBody) { + theFetchParams.body = finalBody; + } + + const url = new URL( + `/${configParams.apiVersion}${path}`, + configParams.baseUrl ?? DEFAULT_BASE_URL, + ).toString(); + + this.emit('start', { + ...theFetchParams, + url, + }); + + return fetchInstance(url, theFetchParams); + }; + + const consumeStream = async (response: Response) => { + // eslint-disable-next-line no-restricted-syntax + for await (const chunk of response.body as unknown as PassThrough) { + this.emit('data', chunk); + } + }; + + this.getVoices = getVoices.bind(this, doFetch); + this.createTextToSpeech = createTextToSpeech.bind(this, doFetch, consumeStream); + } +} diff --git a/src/platforms/elevenlabs/features/tts.ts b/src/platforms/elevenlabs/features/tts.ts new file mode 100644 index 0000000..1af5708 --- /dev/null +++ b/src/platforms/elevenlabs/features/tts.ts @@ -0,0 +1,43 @@ +import { ConsumeStream, DoFetch } from '../../../packages/request'; +import { PlatformApiError } from '../../../common'; + +export interface CreateTextToSpeechParams { + voiceId: string; + text: string; + voiceSettings?: { + stability?: number; + similarityBoost?: number; + }; +} + +export function createTextToSpeech( + this: NodeJS.EventEmitter, + doFetch: DoFetch, + consumeStream: ConsumeStream, + params: CreateTextToSpeechParams, +) { + doFetch('POST', `/text-to-speech/${params.voiceId}/stream`, { + text: params.text, + voice_settings: params.voiceSettings, + } as Record) + .then(async (response) => { + if (!response.ok) { + this.emit('error', new PlatformApiError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Create chat completion returned with status: ${response.status}`, + response, + )); + this.emit('end'); + return; + } + + await consumeStream(response); + this.emit('end'); + }) + .catch((err) => { + this.emit('error', err as Error); + this.emit('end'); + }); + + return this; +} diff --git a/src/platforms/elevenlabs/features/voice.ts b/src/platforms/elevenlabs/features/voice.ts new file mode 100644 index 0000000..cf6027d --- /dev/null +++ b/src/platforms/elevenlabs/features/voice.ts @@ -0,0 +1,38 @@ +import { DoFetch } from '../../../packages/request'; +import { PlatformApiError } from '../../../common'; + +// https://docs.elevenlabs.io/api-reference/voices + +export interface Voice { + voice_id: string; + name: string; + category: string; +} + +export function getVoices( + this: NodeJS.EventEmitter, + doFetch: DoFetch, +) { + doFetch('GET', '/voices') + .then(async (response) => { + if (!response.ok) { + this.emit('error', new PlatformApiError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Request from platform returned with status: ${response.status}`, + response, + )); + this.emit('end'); + return; + } + + const responseData = await response.json() as Record; + this.emit('data', responseData.voices as Voice[]); + this.emit('end'); + }) + .catch((err) => { + this.emit('error', err as Error); + this.emit('end'); + }); + + return this; +} diff --git a/src/platforms/elevenlabs/index.ts b/src/platforms/elevenlabs/index.ts index 2b97806..36ffc60 100644 --- a/src/platforms/elevenlabs/index.ts +++ b/src/platforms/elevenlabs/index.ts @@ -1,6 +1,7 @@ import { Configuration } from './common'; export * from './common'; +export { PlatformEventEmitter, PlatformEventEmitterImpl } from './events'; export const PLATFORM_ID = 'elevenlabs' as const; diff --git a/src/platforms/openai/common.ts b/src/platforms/openai/common.ts index 3ea9e3f..da4c01a 100644 --- a/src/platforms/openai/common.ts +++ b/src/platforms/openai/common.ts @@ -19,26 +19,7 @@ export interface CreatedResource { created: Timestamp; } -export type DoFetchBody = BodyInit | Record - -export type DoFetch = ( - method: string, - path: string, - body?: DoFetchBody -) => Promise; - -export type ConsumeStream = ( - response: Response, -) => Promise; - -export class PlatformError extends Error { - constructor(message: string, readonly response: Response) { - super(message); - this.name = 'OpenAi.PlatformError'; - } -} - -export const enum ApiVersion { +export enum ApiVersion { V1 = 'v1', } @@ -48,3 +29,5 @@ export interface Configuration { apiKey: string; baseUrl?: string; } + +export const DEFAULT_BASE_URL = 'https://api.openai.com' as const; diff --git a/src/platforms/openai/events.ts b/src/platforms/openai/events.ts index 958561e..4804451 100644 --- a/src/platforms/openai/events.ts +++ b/src/platforms/openai/events.ts @@ -1,7 +1,8 @@ import { PassThrough } from 'stream'; import { EventEmitter } from 'events'; import fetchPonyfill from 'fetch-ponyfill'; -import { Configuration, DoFetchBody } from './common'; +import * as AllPlatformsCommon from '../../common'; +import { Configuration, DEFAULT_BASE_URL } from './common'; import { createTextCompletion, CreateTextCompletionParams } from './features/text-completion'; import { CreateChatCompletionParams, createChatCompletion } from './features/chat-completion'; import { @@ -14,12 +15,9 @@ import { } from './features/image'; import { CreateEditParams, createEdit } from './features/edit'; import { listModels } from './features/model'; +import { DoFetchBody, processRequest } from '../../packages/request'; -export type DataEventCallback = (data: D) => void; - -export type ErrorEventCallback = (event: Error) => void; - -export interface PlatformEventEmitter extends NodeJS.EventEmitter { +export interface PlatformEventEmitter extends AllPlatformsCommon.PlatformEventEmitter { createChatCompletion(params: CreateChatCompletionParams): void; createImage(params: CreateImageParams): void; createImageEdit(params: CreateImageEditParams): void; @@ -27,9 +25,6 @@ export interface PlatformEventEmitter extends NodeJS.EventEmitter { createCompletion(params: CreateTextCompletionParams): void; createEdit(params: CreateEditParams): void; listModels(): void; - on(event: 'data', callback: DataEventCallback): this; - on(event: 'end', callback: () => void): this; - on(event: 'error', callback: ErrorEventCallback): this; } export class PlatformEventEmitterImpl extends EventEmitter implements PlatformEventEmitter { @@ -59,31 +54,30 @@ export class PlatformEventEmitterImpl extends EventEmitter implements PlatformEv const { fetch: fetchInstance } = fetchPonyfill(); const doFetch = (method: string, path: string, body?: DoFetchBody) => { - const requestHeaders = { + let finalBody: BodyInit | undefined; + let finalHeaders = { ...platformHeaders, }; - - let theBody: BodyInit; - - if ( - body instanceof FormData - || body instanceof URLSearchParams - ) { - theBody = body; - } else { - theBody = JSON.stringify(body); - requestHeaders['Content-Type'] = 'application/json'; + if (body) { + const finalRequest = processRequest(body, finalHeaders); + finalBody = finalRequest.body; + if (finalRequest.headers) { + finalHeaders = finalRequest.headers; + } } - const theFetchParams = { + const theFetchParams: Record = { method, - headers: requestHeaders, - body: theBody, + headers: finalHeaders, }; + if (finalBody) { + theFetchParams.body = finalBody; + } + const url = new URL( `/${configParams.apiVersion}${path}`, - configParams.baseUrl ?? 'https://api.openai.com', + configParams.baseUrl ?? DEFAULT_BASE_URL, ).toString(); this.emit('start', { diff --git a/src/platforms/openai/features/chat-completion.ts b/src/platforms/openai/features/chat-completion.ts index b65455c..ea5bb42 100644 --- a/src/platforms/openai/features/chat-completion.ts +++ b/src/platforms/openai/features/chat-completion.ts @@ -1,9 +1,6 @@ import { FinishableChoiceBase, - ConsumeStream, DataEventId, - DoFetch, - PlatformError, CreatedResource, } from '../common'; import { @@ -11,6 +8,8 @@ import { } from '../usage'; import { ChatCompletionModel } from '../models'; import { normalizeChatMessage, Message, MessageObject } from '../chat'; +import { ConsumeStream, DoFetch } from '../../../packages/request'; +import { PlatformApiError } from '../../../common'; export interface CreateChatCompletionParams { messages: Message | Message[]; @@ -75,7 +74,7 @@ export function createChatCompletion( } as Record) .then(async (response) => { if (!response.ok) { - this.emit('error', new PlatformError( + this.emit('error', new PlatformApiError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Create chat completion returned with status: ${response.status}`, response, diff --git a/src/platforms/openai/features/edit.ts b/src/platforms/openai/features/edit.ts index a9f6a96..f4ff64d 100644 --- a/src/platforms/openai/features/edit.ts +++ b/src/platforms/openai/features/edit.ts @@ -1,13 +1,13 @@ import { ChoiceBase, - DoFetch, - PlatformError, CreatedResource, } from '../common'; import { UsageMetadata, } from '../usage'; import { EditModel } from '../models'; +import { DoFetch } from '../../../packages/request'; +import { PlatformApiError } from '../../../common'; export enum DataEventObjectType { EDIT = 'edit', @@ -46,7 +46,7 @@ export function createEdit( } as Record) .then(async (response) => { if (!response.ok) { - this.emit('error', new PlatformError( + this.emit('error', new PlatformApiError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Request from platform returned with status: ${response.status}`, response, diff --git a/src/platforms/openai/features/image.ts b/src/platforms/openai/features/image.ts index fa440f4..4c8e924 100644 --- a/src/platforms/openai/features/image.ts +++ b/src/platforms/openai/features/image.ts @@ -1,9 +1,9 @@ import * as FormDataUtils from '../../../packages/form-data'; import { - DoFetch, - PlatformError, CreatedResource, } from '../common'; +import { DoFetch } from '../../../packages/request'; +import { PlatformApiError } from '../../../common'; export enum ImageSize { SQUARE_256 = '256x256', @@ -45,7 +45,7 @@ export function createImage( } as Record) .then(async (response) => { if (!response.ok) { - this.emit('error', new PlatformError( + this.emit('error', new PlatformApiError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Request from platform returned with status: ${response.status}`, response, @@ -94,7 +94,7 @@ export function createImageEdit( })) .then(async (response) => { if (!response.ok) { - this.emit('error', new PlatformError( + this.emit('error', new PlatformApiError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Request from platform returned with status: ${response.status}`, response, @@ -139,7 +139,7 @@ export function createImageVariation( })) .then(async (response) => { if (!response.ok) { - this.emit('error', new PlatformError( + this.emit('error', new PlatformApiError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Request from platform returned with status: ${response.status}`, response, diff --git a/src/platforms/openai/features/model.ts b/src/platforms/openai/features/model.ts index c22f4fc..e0374ec 100644 --- a/src/platforms/openai/features/model.ts +++ b/src/platforms/openai/features/model.ts @@ -1,4 +1,5 @@ -import { DoFetch, PlatformError } from '../common'; +import { DoFetch } from '../../../packages/request'; +import { PlatformApiError } from '../../../common'; export enum DataEventObjectType { MODEL = 'model', @@ -18,7 +19,7 @@ export function listModels( doFetch('GET', '/models') .then(async (response) => { if (!response.ok) { - this.emit('error', new PlatformError( + this.emit('error', new PlatformApiError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Request from platform returned with status: ${response.status}`, response, diff --git a/src/platforms/openai/features/text-completion.ts b/src/platforms/openai/features/text-completion.ts index c169667..31bd859 100644 --- a/src/platforms/openai/features/text-completion.ts +++ b/src/platforms/openai/features/text-completion.ts @@ -1,15 +1,14 @@ import { TextCompletionModel } from '../models'; import { - ConsumeStream, DataEventId, - DoFetch, FinishableChoiceBase, - PlatformError, CreatedResource, } from '../common'; import { UsageMetadata, } from '../usage'; +import { ConsumeStream, DoFetch } from '../../../packages/request'; +import { PlatformApiError } from '../../../common'; export enum DataEventObjectType { TEXT_COMPLETION = 'text_completion', @@ -76,7 +75,7 @@ export function createTextCompletion( } as Record) .then(async (response) => { if (!response.ok) { - this.emit('error', new PlatformError( + this.emit('error', new PlatformApiError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Create text completion returned with status: ${response.status}`, response,