feat(provider-transformers): added package to integrate transformers.js with xsai (#36)

* feat(provider-transformers): added package to integrate transformers.js with xsai
* fix: build
* chore: use updated xsai

---------

Signed-off-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
Neko
2025-02-27 16:12:06 +08:00
committed by GitHub
parent de97bd088f
commit 6d4b534be8
27 changed files with 1142 additions and 452 deletions
+13
View File
@@ -0,0 +1,13 @@
[build]
base = "/"
command = "pnpm -F @proj-airi/provider-transformers... run build"
publish = "/packages/provider-transformers/playground/dist"
[build.environment]
NODE_VERSION = "23"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = false
@@ -0,0 +1,74 @@
{
"name": "@proj-airi/provider-transformers",
"type": "module",
"private": true,
"description": "Transformers.js wrapper makes it easy to use with xsai",
"author": {
"name": "Neko Ayaka",
"email": "neko@ayaka.moe",
"url": "https://github.com/nekomeowww"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "packages/provider-transformers"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs"
},
"./worker": {
"types": "./dist/worker/index.d.ts",
"import": "./dist/worker/index.mjs"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.mjs"
}
},
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"README.md",
"dist",
"package.json"
],
"scripts": {
"dev": "pnpm run stub && pnpm run play:dev",
"stub": "unbuild --stub",
"build": "unbuild && pnpm run play:build",
"play:dev": "vite",
"play:build": "vite build",
"play:preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"web-worker": "^1.5.0"
},
"peerDependenciesMeta": {
"web-worker": {
"optional": true
}
},
"dependencies": {
"@huggingface/transformers": "^3.3.3",
"@proj-airi/utils-transformers": "workspace:^",
"@webgpu/types": "^0.1.54",
"@xsai-ext/shared-providers": "catalog:",
"@xsai/embed": "catalog:",
"@xsai/shared": "catalog:",
"defu": "^6.1.4",
"es-toolkit": "^1.32.0"
},
"devDependencies": {
"@iconify-json/solar": "^1.2.2",
"@unocss/reset": "^66.1.0-beta.3",
"@vitejs/plugin-vue": "^5.2.1",
"@vueuse/core": "^12.7.0",
"vite": "^6.2.0",
"vue": "^3.5.13",
"vue-tsc": "^2.2.4"
}
}
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Project AIRI 🤗 Transformers.js xsai provider Playground</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script>
;(function () {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
})()
</script>
</head>
<body class="font-sans">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none">
<path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z"></path>
<path fill="#fd7f9c" d="M18.296 3.045a1 1 0 0 1 .657.652l.03.119l1.341 7.154q.154.826.148 1.63l-.012.4H21a1 1 0 0 1 .117 1.993L21 15h-.894q-.164.531-.392 1.033l-.16.33l1.07.856a1 1 0 0 1-1.146 1.634l-.103-.072l-.936-.749A8.43 8.43 0 0 1 12 21a8.42 8.42 0 0 1-6.25-2.755l-.19-.213l-.935.749a1 1 0 0 1-1.343-1.477l.093-.085l1.07-.856a9 9 0 0 1-.435-1.012L3.894 15H3a1 1 0 0 1-.117-1.993L3 13h.54a8.5 8.5 0 0 1 .069-1.619l.067-.411l1.341-7.154a1 1 0 0 1 1.598-.604l.092.08l2.414 2.415a1 1 0 0 0 .576.284L9.828 6h4.344a1 1 0 0 0 .608-.206l.099-.087l2.414-2.414a1 1 0 0 1 1.003-.248m-.93 3.003L16.293 7.12a3 3 0 0 1-2.121.88H9.828a3 3 0 0 1-2.12-.879L6.632 6.048l-.992 5.29A6.5 6.5 0 0 0 5.545 13H7a1 1 0 1 1 0 2h-.492a.998.998 0 0 1 .71 1.696l-.095.086A6.44 6.44 0 0 0 12 19a6.43 6.43 0 0 0 4.696-2.02l.18-.2a1 1 0 0 1 .616-1.78H17a1 1 0 1 1 0-2h1.455a6.5 6.5 0 0 0-.096-1.662zm-3.472 9.005a1 1 0 0 1-.447 1.342l-.553.276a2 2 0 0 1-1.788 0l-.553-.276a1 1 0 0 1 .894-1.79l.553.277l.553-.276a1 1 0 0 1 1.341.447M9.5 10a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3m5 0a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3"></path>
</g>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'
import { embed } from '@xsai/embed'
import { serialize } from 'superjson'
import { onMounted, ref } from 'vue'
import { createTransformers } from '../../src'
import embedWorkerURL from '../../src/worker?url'
const isDark = useDark()
const toggleDark = useToggle(isDark)
const modelId = ref('Xenova/all-MiniLM-L6-v2')
const input = ref('Hello, world!')
const results = ref<any>()
const transformersProvider = createTransformers({ embedWorkerURL })
onMounted(async () => {
await transformersProvider.loadEmbed(modelId.value)
})
async function execute() {
const result = await embed({
...transformersProvider.embed(modelId.value),
model: modelId.value,
input: input.value,
})
results.value = result
}
</script>
<template>
<div flex flex-col gap-2 p-4>
<header flex flex-row items-center justify-between>
<h1 text-2xl>
<a href="https://huggingface.co/docs/transformers.js/index">🤗 Transformers.js</a> + <a
href="https://github.com/moeru-ai/xsai"
>xsai</a> Playground
</h1>
<button text-lg @click="() => toggleDark()">
<div v-if="isDark" i-solar:moon-stars-bold-duotone />
<div v-else i-solar:sun-bold />
</button>
</header>
<div flex flex-col gap-2>
<h2 text-xl>
Options
</h2>
<div w-full flex flex-row gap-2>
<div w-full flex flex-row gap-2>
<label flex flex-row items-center gap-2>
<div text-nowrap><span>Model ID</span></div>
<input v-model="modelId" bg="neutral-100 dark:neutral-800" block min-w-full w-full rounded-lg p-2>
</label>
</div>
</div>
</div>
<div grid grid-cols-2 gap-2>
<div flex flex-col gap-2>
<h2 text-xl>
Inference
</h2>
<div>
<textarea v-model="input" h-full w-full rounded-lg bg="neutral-100 dark:neutral-800" p-4 font-mono />
</div>
<div flex flex-row gap-2>
<button rounded-lg bg="blue-100 dark:blue-900" px-4 py-2 @click="execute">
Execute
</button>
</div>
<div flex flex-col gap-2>
<h2 text-xl>
Results
</h2>
<div max-h-100 of-y-scroll whitespace-pre-wrap p-4 font-mono>
{{ JSON.stringify(serialize(results).json, null, 2) }}
</div>
</div>
</div>
</div>
</div>
</template>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
html {
background: #fff;
transition: all 0.3s ease-in-out;
}
html.dark {
background: #121212;
color-scheme: dark;
}
</style>
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@unocss/reset/tailwind.css'
import 'uno.css'
createApp(App)
.mount('#app')
+107
View File
@@ -0,0 +1,107 @@
import type { CreateProviderOptions, EmbedProviderWithExtraOptions } from '@xsai-ext/shared-providers'
import type { EmbedResponse } from '@xsai/embed'
import type { CommonRequestOptions } from '@xsai/shared'
import type { LoadOptions, WorkerMessageEvent } from './types'
import { merge } from '@xsai-ext/shared-providers'
export type Loadable<P, T = string, T2 = undefined> = P & {
loadEmbed: (model: (string & {}) | T, options?: T2) => Promise<void>
}
export function createEmbedProvider<T extends string, T2 extends CommonRequestOptions & LoadOptions>(createOptions: CreateProviderOptions): Loadable<EmbedProviderWithExtraOptions<T, T2>, T, T2> {
let worker: Worker
let isReady = false
function loadModel(model: (string & {}) | T, options: T2) {
return new Promise<void>((resolve, reject) => {
try {
const workerURL = new URL(createOptions.baseURL)
if (!worker)
worker = new Worker(workerURL.searchParams.get('worker-url')!, { type: 'module' })
if (!worker)
throw new Error('Worker not initialized')
worker.postMessage({ type: 'load', data: { modelId: model, task: 'feature-extraction', options } } satisfies WorkerMessageEvent)
}
catch (err) {
reject(err)
}
worker.onmessage = (event: MessageEvent<WorkerMessageEvent>) => {
switch (event.data.type) {
case 'error':
reject(event.data.data.error)
break
case 'status':
if (event.data.data.status === 'ready') {
isReady = true
resolve()
}
break
}
}
})
}
return {
embed: (model, options) => Object.assign(createOptions, {
fetch: (_, init: RequestInit) => {
return new Promise<Response>((resolve, reject) => {
loadModel(model, options).then(() => {
if (!worker || !isReady) {
reject(new Error('Model not loaded'))
return
}
let text: string = ''
let body: any
try {
body = JSON.parse(init.body.toString())
text = body.input
delete body.input
}
catch (err) {
reject(err)
return
}
let errored = false
let resultDone = false
worker.onmessage = (event: MessageEvent<WorkerMessageEvent>) => {
switch (event.data.type) {
case 'error':
errored = true
reject(event.data.data.error)
break
case 'extractResult':
resultDone = true
// eslint-disable-next-line no-case-declarations
const result = { data: [{ embedding: event.data.data.output.data, index: 0, object: 'embedding' }], model, object: 'list', usage: { prompt_tokens: 0, total_tokens: 0 } } satisfies EmbedResponse
// eslint-disable-next-line no-case-declarations
const encoder = new TextEncoder()
resolve(new Response(encoder.encode(JSON.stringify(result))))
break
}
}
if (!errored && !resultDone)
worker.postMessage({ type: 'extract', data: { text, options: body as any } } satisfies WorkerMessageEvent)
})
})
},
}) as unknown as T2,
loadEmbed: loadModel,
}
}
export function createTransformers(options: { embedWorkerURL: string }) {
return merge(
createEmbedProvider<'Xenova/all-MiniLM-L6-v2', CreateProviderOptions & LoadOptions & { model: string }>({ baseURL: `xsai-provider-ext:///?worker-url=${options.embedWorkerURL}&other=` }),
)
}
@@ -0,0 +1,54 @@
import type { FeatureExtractionPipelineOptions, pipeline, ProgressInfo } from '@huggingface/transformers'
import type { PipelineOptionsFrom } from '@proj-airi/utils-transformers/types'
export enum MessageStatus {
Loading = 'loading',
Ready = 'ready',
}
export type LoadOptions = Omit<PipelineOptionsFrom<typeof pipeline<'feature-extraction'>>, 'progress_callback'>
export interface WorkerMessageBaseEvent<T, D> {
type: T
data: D
}
export interface WorkerMessageEvents {
load: {
task: string
modelId: string
options?: LoadOptions
}
error: {
error?: unknown
message?: string
}
status: {
status: MessageStatus
message?: string
}
info: {
message: string
}
progress: {
progress: ProgressInfo
}
extract: {
text: string | string[]
options?: FeatureExtractionPipelineOptions
}
extractResult: {
input: {
text: string | string[]
options?: FeatureExtractionPipelineOptions
}
output: {
data: number[]
dims: number[]
}
}
}
export type WorkerMessageEvent = {
[K in keyof WorkerMessageEvents]: WorkerMessageBaseEvent<K, WorkerMessageEvents[K]>;
}[keyof WorkerMessageEvents]
@@ -0,0 +1,13 @@
export async function supportsWebGPU() {
try {
if (!('gpu' in navigator) || !navigator.gpu)
return false
await navigator.gpu.requestAdapter()
return true
}
catch (e) {
console.error(e)
return false
}
}
@@ -0,0 +1,54 @@
/* eslint-disable no-restricted-globals */
import type { FeatureExtractionPipeline, FeatureExtractionPipelineOptions } from '@huggingface/transformers'
import type { PipelineOptionsFrom } from '@proj-airi/utils-transformers/types'
import type { WorkerMessageEvent } from '../types'
import { pipeline } from '@huggingface/transformers'
import { defu } from 'defu'
import { MessageStatus } from '../types'
import { supportsWebGPU } from '../utils'
let embed: FeatureExtractionPipeline
async function load(modelId: string, options?: Omit<PipelineOptionsFrom<typeof pipeline<'feature-extraction'>>, 'progress_callback'>) {
try {
const device = (await supportsWebGPU()) ? 'webgpu' : 'wasm'
const opts = defu<PipelineOptionsFrom<typeof pipeline<'feature-extraction'>>, PipelineOptionsFrom<typeof pipeline<'feature-extraction'>>[]>(options, {
device,
progress_callback: (progress) => {
self.postMessage({ type: 'progress', data: { progress } } satisfies WorkerMessageEvent)
},
})
self.postMessage({ type: 'info', data: { message: `Using device: "${device}"` } } satisfies WorkerMessageEvent)
self.postMessage({ type: 'info', data: { message: 'Loading models...' } } satisfies WorkerMessageEvent)
embed = await pipeline('feature-extraction', modelId, opts)
self.postMessage({ type: 'status', data: { status: MessageStatus.Ready, message: 'Ready!' } } satisfies WorkerMessageEvent)
}
catch (err) {
self.postMessage({ type: 'error', data: { error: err } } satisfies WorkerMessageEvent)
throw err
}
}
async function extract(text: string | string[], options?: FeatureExtractionPipelineOptions) {
const result = await embed(text, options)
self.postMessage({ type: 'extractResult', data: { input: { text, options }, output: { data: Array.from(result.data), dims: result.dims } } } satisfies WorkerMessageEvent)
}
self.addEventListener('message', (event: MessageEvent<WorkerMessageEvent>) => {
const { type } = event.data
switch (type) {
case 'load':
load(event.data.data.modelId, event.data.data.options)
break
case 'extract':
extract(event.data.data.text, event.data.data.options)
break
}
})
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable",
"WebWorker"
],
"module": "ESNext",
"moduleResolution": "bundler",
"types": [
"vite/client",
// @webgpu/types
// https://www.npmjs.com/package/@webgpu/types
"@webgpu/types"
],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}
@@ -0,0 +1,33 @@
import {
defineConfig,
presetAttributify,
presetIcons,
presetTypography,
presetWebFonts,
presetWind3,
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
export default defineConfig({
presets: [
presetWind3(),
presetAttributify(),
presetTypography(),
presetWebFonts({
fonts: {
sans: 'DM Sans',
serif: 'DM Serif Display',
mono: 'DM Mono',
},
}),
presetIcons({
scale: 1.2,
}),
],
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
safelist: 'prose prose-sm m-auto text-left'.split(' '),
})
@@ -0,0 +1,13 @@
import Vue from '@vitejs/plugin-vue'
import Unocss from 'unocss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
root: 'playground',
plugins: [
Vue(),
// https://github.com/antfu/unocss
// see uno.config.ts for config
Unocss(),
],
})
@@ -0,0 +1,32 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
workspace: [
{
extends: true,
test: {
name: 'node',
environment: 'node',
include: ['**/*.{spec,test}.ts'],
exclude: ['**/*.browser.{spec,test}.ts', '**/node_modules/**'],
},
},
{
extends: true,
test: {
name: 'browser',
include: ['**/*.browser.{spec,test}.ts'],
exclude: ['**/node_modules/**'],
browser: {
enabled: true,
provider: 'playwright',
instances: [
{ browser: 'chromium' },
],
},
},
},
],
},
})
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@proj-airi/utils-transformers",
"type": "module",
"private": true,
"description": "Utility functions and types for Transformers.js",
"author": {
"name": "Neko Ayaka",
"email": "neko@ayaka.moe",
"url": "https://github.com/nekomeowww"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "packages/utils-transformers"
},
"exports": {
"./types": {
"types": "./dist/types/index.d.ts"
}
},
"types": "./dist/types/index.d.ts",
"files": [
"README.md",
"dist",
"package.json"
],
"scripts": {
"dev": "pnpm run stub",
"stub": "unbuild --stub",
"build": "unbuild",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@huggingface/transformers": "^3.3.3"
}
}
@@ -0,0 +1,7 @@
import type { AutoModel, pipeline } from '@huggingface/transformers'
export type DType = Record<string, Exclude<NonNullable<Required<Parameters<typeof pipeline>>[2]['dtype']>, string>[string]>
export type Device = Extract<Exclude<NonNullable<Required<Parameters<typeof pipeline>>[2]['device']>, Record<string, any>>, 'webgpu' | 'wasm'>
export type PretrainedConfig = NonNullable<Parameters<typeof AutoModel.from_pretrained>[1]>['config']
export type PretrainedConfigFrom<T> = T extends { from_pretrained: (...args: any) => any } ? NonNullable<Parameters<T['from_pretrained']>[1]>['config'] : never
export type PipelineOptionsFrom<T> = T extends (...args: any) => any ? NonNullable<Parameters<T>[2]> : never
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"ESNext"
],
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}