我开发了一个极简的LLM提供商编辑器,它长下面这样👇:

这篇文章主要介绍 ai-sdk-panel 的构建过程中使用到一些技术和技巧,希望对看的人有所帮助。
使用
bash
pnpm i @matrixages/ai-sdk-panel
tsx
import { preset_providers } from '@matrixages/ai-sdk-panel'
const Page = () => {
const props_providers: IPropsProviders = {
config: { providers: preset_providers },
tab: 'between',
width: 690,
onChange: useMemoizedFn(v => {
console.log(v)
}),
onTest: useMemoizedFn(async () => {
await sleep(500)
return true
})
}
return (
<div
className='
flex justify-center
w-screen min-h-screen
py-20
bg-amber-100/20
dark:bg-amber-100/6
'
>
<Providers {...props_providers} />
</div>
)
}
缘由
在过去的一段时间里,做的几个项目都需要做 LLM Providers 的编辑,存在重复造轮子的情况。为了让之后的 AI 应用能不再重复造轮子,我开发了ai-sdk-panel这个 React LLM Providers 编辑器,集成了 Vercel AI SDK 中大部分 Providers,以后做 AI 应用,不需要再重复造一个编辑大模型提供商的轮子了,直接引入集成ai-sdk-panel即可。
技术选型
ai-sdk-panel是基于 react 构建的,虽然 react 有很许多问题,但架不住它生态最丰富,属于时又恨又爱,但不得不用。
状态管理我选择了valtio这个 zustand 作者 dai-shi 开发的一个基于 "mutable" 的状态管理库,为什么说是 "mutable" 呢?因为 dai-shi 的 zustand 就是 "immutable"状态管理集大成之作。
这可以说是 react 状态管理的两个流派:Immutable(不可变) vs Mutable(可变)。
Immutable
-
定义: 一旦一个状态对象被创建,它就不能被修改。如果需要更新状态,你必须创建一个新的状态对象,并用新的值替换旧的对象。
-
优点:
- 简化变更检测: react 通过比较引用地址就能轻松判断状态是否发生变化,从而优化组件的重新渲染。如果引用地址不同,表示状态已更新;如果相同,则表示没有变化。
- 可预测性: 状态不会在组件之间被意外修改,减少了bug的可能性。
- 方便调试: 容易追溯状态变化的来源。
- 并发安全: 在多线程或并发环境下更安全,因为不存在数据竞态条件。
-
缺点:
- 性能开销: 频繁创建新的对象可能会带来一定的内存和CPU开销,尤其是在深层嵌套的对象中。
- 代码冗余: 更新深层嵌套数据时,可能需要使用扩展运算符(
...)或者像immer这样的库来简化操作。
-
示例:
const newArray = [...oldArray, newItem];或const newUser = { ...oldUser, name: 'NewName' };
Mutable
-
定义: 状态对象可以在创建后被直接修改(in-place modification)。
-
优点:
- 性能: 直接修改对象通常比创建新对象效率更高,内存占用更少。
- 代码简洁: 对于简单的修改,代码可能更直接。
-
缺点:
- 难以追踪变更: react 难以检测到状态何时发生了变化,因为引用地址没有变。这可能导致组件不重新渲染或意外的副作用。
- 不可预测性: 状态可能在应用的不同部分被意外修改,导致难以调试的bug。
- 并发风险: 在并发环境中容易出现数据竞态问题。
-
示例:
oldArray.push(newItem);或oldUser.name = 'NewName';
在这之前,我使用过 redux,dvajs,react useContext,zustand 以及 mobx,后面基本上都是使用 mobx 做开发了,配合 rfdc 这个用于将引用对象"深克隆"成新对象的库,既保留了响应式开发的无需写太多模版语法的优点,又保留了 react 不可变数据流的特性,当然,这也是一种"tradeoff",代价就是在应用数据流顶层需要有一层数据深克隆的开销,不过对于 rfdc 这种高性能的深克隆库,一个组件或者页面假如有 20 个响应式变量,每次克隆成本在 40ms 左右,完全可以接受。
为什么在ai-sdk-panel中不选择 mobx,而选择使用valtio呢?
打包产物体积是主要因素,mobx 打包出来的体积太大了(相比于 valtio 而言多出250kb+),对于应用开发而言,mobx 是首选,而对于组件,特别是 ai-sdk-panel 这种单一功能的组件,体积越小越好,曾考虑过用 react 的 useContext 实现组件内部所有的状态管理,但是写了一点发现,性能太差了,即便加上 use-context-selector,性能还是有问题(use-context-selector 是会影响调用组件的,对调用组件子组件无副作用)。最终选定了 valtio 作为状态管理。
使用了一些 tricks,让 valtio 能实现 mobx 那样的写法,通过 class 保存状态,通过 useRef 保持 class instance 的引用,通过 valtio 提供的 useProxy 函数为 class 提供响应式变量到组件的 binding。
model.ts
ts
import { ref } from 'valtio'
import { autoBind } from '@/utils'
export default class Index {
config = null as Config | null
current_tab = 0
current_model = null as number | null
test = { loading: false, res: null as boolean | null }
adding_model = false
adding_provider = false
upload_error = ''
refs = ref({
locales_upload: {} as ProvidersLocales['upload'],
timer_test: null as NodeJS.Timeout | null,
onChange: null as unknown as IPropsProviders['onChange'],
onTest: null as unknown as IPropsProviders['onTest']
})
init(args: ArgsInit) {
const { locales_upload, config, onChange, onTest } = args
this.config = deepClone(config)
this.refs.locales_upload = locales_upload
this.refs.onChange = onChange
this.refs.onTest = onTest
autoBind(this)
}
}
index.tsx
tsx
const Index = (props: IPropsProviders) => {
const { config, tab, width, locales, icons, onChange, onTest } = props
const state = useRef(proxy(new Model()))
const x = useProxy(state.current)
const target_config = deepClone(x.config)
...
}
变量的消费形式上和 mobx 一样,不同的是,mobx "观察"的是整个组件中使用的引用,有个依赖收集的过程,而valtio 是通过 useProxy 通知组件进行更新,我觉得 valtio 这种形式可能更贴合 react 一些。
tailwind.css
css 方面用的是 tailwind.css,大概6年前,那时候做前端有一年多的时间了,我开源了一个原子化 css 库------atom.css,使用至今,如果不是 shadcn,我大概率不会使用 tailwind.css,我特别嫌弃那种在 html 模版中写超长一串 classnames,不过还是有好处的,能最大化减小包体积,写独立组件是有用的,还有一个优势------Nextjs hmr 速度更快,使用 css modules 修改css 后的 hmr 速度与直接修改组件上的 tailwind.css class 名称相比会慢很多。
不过我有强迫症,不能忍受 tailwind.css 的 classnames 不能进行分类,我研究了 prettier-plugin-tailwindcss 这个插件,它能对 tailwind classnames 进行排序,但不能进行分组,离我想要的 clean code 还有一定距离.
我找到了 tailwind-formatter 这个 vscode 插件,它能实现如下图的效果:

但使用一会之后发现,它有两个严重bug:
- 它会对 JSX/TSX 的 dom 属性进行强制排序,并忽略展开属性 {...props} 这种属性会被自动消除。
- 它无法识别项目使用的 prettier plugin,查看了一下代码发现识别 prettier plugin 的路径是错误的。
这个插件已经有一年未更新,为此,我 folk 了它的代码,取名为 [Tailwind Better Formatter],修复了上述两个问题,在 vscode 中搜索 1yasa.tailwind-better-formatter 即可使用:

base-ui
组件库使用的是 base-ui 这个 Material UI 团队开发的 极简 的 headless 组件库。其实有考虑过使用 shadcn,但是引入之后发现,其背后的 radix ui 组件库直接导致包体积增加了 500kb+,仅使用一个 Switch 组件,这完全不能让人接受,于是移除了所有相关依赖,转而使用更简便的 base ui。
base-ui 好用的地方在于,可完全基于 tailwind.css 进行定制,其状态逻辑无副作用,不会引入一大堆依赖,对于这种需要优先考虑包体积的组件是十分合适的,当然,如果是做应用开发,我可能更愿意使用 ant design 或者 shadcn 这样的库,虽然性能都不怎么样,但包装得够完善,想要什么直接用,不用太多定制。
基于 base-ui 包装的 Switch 组件
tsx
import { memo } from '@/utils'
import { Switch } from '@base-ui-components/react/switch'
import type { ControllerRenderProps } from 'react-hook-form'
const Index = (props: Partial<ControllerRenderProps>) => {
const { name, value, ref, onChange } = props as ControllerRenderProps
return (
<div onClick={e => e.stopPropagation()}>
<Switch.Root
className='
flex
h-4 w-7
p-px
bg-soft/30
transition-[background]
rounded-full
data-[checked]:bg-solid
'
name={name}
checked={value}
inputRef={ref}
onCheckedChange={onChange}
>
<Switch.Thumb
className='
h-full
bg-std-white
transition-[translate]
rounded-full
data-[checked]:translate-x-[calc(var(--spacing)*7-var(--spacing)*4)]
aspect-square
'
/>
</Switch.Root>
</div>
)
}
export default memo(Index)
react-hook-form
表单方案使用的是 react-hook-form 这个专注于 form 的库,它是 headless 的,未和固定组件进行绑定,其内部也是基于可变数据构建的数据流方案,性能方面自然是不会差的,它还支持动态注册 Array Fields,通过 useFieldArray 十分方便地对表单中的数组集合进行增删改查拖拽等操作。
tsx
const { fields, prepend, remove, move } = useFieldArray({
control,
name: 'models',
keyName: '_'
})
const target_fields = useMemo(() => {
return fields.map(item => {
// @ts-ignore
delete item['_']
return item
})
}, [fields])
⚠️注意:useFieldArray 如果未指定 keyName 会强制占用 id 属性,即便你的数据有 id 字段,它也会使用一个 unique id 覆盖原始 id,而且它生成 unique id 是会每次渲染更新重新生成的(可能是我使用了 deepClone 给被 proxy 数据解除引用导致的)。
在这之前我都使用的是 ant design 的 form 表单方案,与 ant design 的 form 相比,react-hook-form 提供的 useForm 返回 register 函数,只需要通过该函数,就可以将任何 form 组件连接到 form 上,同时,可以通过useForm 返回的 formState,检测表单状态的变化,通过 formState.isDirty 来判断是否发生了表单值的变化。
react-hook-form 还提供了 Controller 这个组件,用于构建自定义的 form 表单项,该组件与 ant design form 中的 Form.Item 类似,我使用了一个 Trick 方式让 Controller 和 Form.Item一样方便使用:
Controller.tsx
tsx
import { cloneElement } from 'react'
import { useMemoizedFn } from 'ahooks'
import { Controller } from 'react-hook-form'
import { memo } from '@/utils'
import type { ReactElement } from 'react'
import type { Control } from 'react-hook-form'
interface IProps {
children: ReactElement
name: string
control: Control<any>
}
const Index = (props: IProps) => {
const { children, name, control } = props
const render = useMemoizedFn(({ field: { value, name, ref, onChange } }) =>
//@ts-ignore
cloneElement(children, { name, value, ref, onChange })
)
return <Controller name={name} control={control} render={render} />
}
export default memo(Index)
直接对组件这样使用即可,省去了高阶函数注入的过程:
tsx
<Controller name='enabled' control={control}>
<Switch />
</Controller>
@lobehub/icons
大模型提供商图标方案使用的是 @lobehub/icons,它是 lobechat 开发的一个集成了绝大部分 LLM 相关图标和 logo 的库。
不过 @lobehub/icons 打包出来的ai-sdk-panel在 nextjs 中无法使用,会报错,一番探查发现 @lobehub/icons 使用的是 antd-styles 这个动态 css in js 方案,其底层是基于 emotion 的,而上述打包出来的组件在 nextjs 中使用报错 document is not defined 的 bug 就是其使用的 emotion 导致的,其实是有解决方案的,需要 nextjs 应用用一个 emotion RegistryContainer 包裹根组件来针对 emotion 的运行环境进行处理,但由于拿不到 emotion 实例,也就无从处理了。
最终解决方案是,我注意到 @lobehub/icons 有一同发布相关的静态图标,找到了 @lobehub/icons-static-svg 这个库,通过 svgr 直接将 svg 导入为 react 组件,使用体验其实和使用 @lobehub/icons 是一样的,不过需要注意的是,@lobehub/icons-static-svg 未提供类型定义文件,需自行添加:
icons.d.ts
ts
declare module '@lobehub/icons-static-svg'
declare module '*.svg?react'
还需要注意,在 nextjs 中开发调试 ai-sdk-panel 时(ai-sdk-panel开发环境),需要为 nextjs 配置 svgr:
next.config.ts
ts
const config: NextConfig = {
reactStrictMode: false,
devIndicators: false,
outputFileTracingRoot: __dirname,
turbopack: {
rules: {
'*.svg': {
as: '*.js',
loaders: [
{
loader: '@svgr/webpack',
options: {
svgo: true,
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false
}
}
},
'prefixIds'
]
}
}
}
]
}
}
}
}
构建
构建使用的是 rslib,我算是 bytedance web-infra rstack 深度开发者用户了,从 rspack alpha 时期开始使用的,现在基本上所有项目都是基于 rsbuild,rslib 构建开发的,优点就是一个字------快!周边生态也完全够用,rollup 有的 rslib,rspack都有,测试基于 rstest,除非有满足不了的,才会使用 vitest 进行测试。
测试
测试使用的是 vitest 4.0 最新推出的 browser mode,在这之前其实花了一天去研究 web-infra 开源的 midscene,不得不吐槽一下 midscene 实在是太难用了,按照文档要求配置了硅基流动上的 Qwen-VL,跑了半天,e2e 测试中的一个都完不成,常常是运行了两分钟,屏幕在疯狂闪烁,然后控制台显示报错了,有些看起来像 playwright selector 的错误,但是抛给你就不管了,只能重来,如此反复,完全无法使用!!!
不知道 midscene 这个项目在开源什么东西(有 kpi 项目的严重嫌疑,文档都像是糊弄的,写的稀烂),不如做成 vscode 插件或是 mcp,专注于 vitest/playwright/jest 相关代码的生成,整的听起来很好用,是理想中的样子,用起来完全是一坨💩。
不得不夸一下 vitest 的 browser mode,真的真的很好用!过去做组件测试,基于 happydom,实际上很难测出来一些渲染样式的问题,getComputedStyle 获取不到真实的渲染过的 dom style,而单独配置 playwright 又没法简单地和 vitest 结合起来使用,vitest browser mode 解决了上述痛点,而且 vitest 还支持为 browser mode 注册自定义 commands,比如获取文件路径,测试基于 playwright 的文件上传和下载:
vitest/file.ts
ts
import { mkdirSync } from 'fs'
import { basename, join, resolve } from 'path'
import type { Download } from 'playwright-core'
import type { BrowserCommand } from 'vitest/node'
export const getPath: BrowserCommand<[string]> = (_, path) => {
return resolve(process.cwd(), `./__test__/${path}`)
}
const finishFileDownload: BrowserCommand<[Promise<Download>]> = async (_, download_promise) => {
const download = await download_promise
const filename = download.suggestedFilename()
const directory = resolve(process.cwd(), './__test__/__downloads__/')
mkdirSync(directory, { recursive: true })
const path = join(directory, filename)
await download.saveAs(path)
return { name: basename(path), path }
}
export const downloadFile: BrowserCommand<[string]> = async (ctx, element_text) => {
const download = ctx.page.waitForEvent('download')
const element = ctx.iframe.getByText(element_text, { exact: true })
await element.click()
return finishFileDownload(ctx, download)
}
export const uploadFile: BrowserCommand<[{ element_text: string; path: string }]> = async (
ctx,
{ element_text, path }
): Promise<void> => {
const file_chooser_promise = ctx.page.waitForEvent('filechooser')
const element = ctx.iframe.getByText(element_text, { exact: true })
await element.click()
const file_chooser = await file_chooser_promise
const target_path = getPath(ctx, path)
await file_chooser.setFiles(target_path)
}
vitest.config.ts
ts
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
import { dependencies } from './package.json'
import { downloadFile, getPath, uploadFile } from './vitest/file'
const ui = Boolean(process.env.UI)
export default defineConfig({
plugins: [react()],
test: {
include: ['__test__/**/*.{test,spec}.{ts,tsx}'],
browser: {
enabled: true,
ui,
provider: playwright(),
instances: [{ browser: 'chromium', headless: !ui }],
viewport: { width: 790, height: 1080 },
commands: {
getPath,
downloadFile,
uploadFile
}
},
pool: 'threads',
maxWorkers: 4
},
resolve: {
alias: {
'@matrixages/ai-sdk-panel': resolve(__dirname, './dist/')
}
},
optimizeDeps: {
include: Object.keys(dependencies).filter(item => item !== '@lobehub/icons-static-svg')
}
})
注册类型提示 vitest.d.ts
ts
import type { Download } from 'playwright-core'
declare module 'vitest/browser' {
interface BrowserCommands {
getPath: (path: string) => Promise<string>
downloadFile: (text: string) => Promise<{ name: string; path: string }>
uploadFile: (args: { element_text: string; path: string }) => Promise<void>
}
}
在测试中使用: provider.test.ts
ts
import { beforeEach, expect, test } from 'vitest'
import { commands, server } from 'vitest/browser'
const { readFile, writeFile, removeFile } = server.commands
// 文件下载测试
test('export config', async () => {
const { name, path } = await commands.downloadFile('Export Config')
expect(name).toBe('ai-sdk-panel.config.json')
expect(path).toBeDefined()
const file = await readFile(path)
const config = JSON.parse(file) as Config
expect(config.providers[0].name).toBe('openai')
})
// 文件上传测试
test('import config', async () => {
const path = '__downloads__/ai-sdk-panel.config.json'
const file_path = await commands.getPath(path)
const file = await readFile(file_path)
const config = JSON.parse(file) as Config
config.providers[0].models!.unshift({ id: 'gpt-y', name: 'GPT Y', enabled: true })
await writeFile(file_path, JSON.stringify(config, null, 6))
await commands.uploadFile({ element_text: 'Import Config', path })
await sleep(600)
const gpt_y = screen.getByText('GPT Y', { selector: 'span', exact: true })
expect(gpt_y).toBeInTheDocument()
expect(config?.providers[0].models![0].id).toBe('gpt-y')
expect(config?.providers[0].models![0].name).toBe('GPT Y')
await removeFile(file_path)
})
做 e2e 测试时,sleep 的时长其实有一定讲究的,比如上传文件后的 sleep 开始定的是 300ms,在本地测试了多少次都是通过的,到 github actions 的云端 ci 环境就是不通过,换成 600ms 就好了,包括一些视觉回归测试,有时候测试不通过的原因就是未进行 sleep,或是 sleep 的时间不够。
github actions
我使用 @jlarky/gha-ts 自动生成 github actions workflow yaml 文件,@jlarky/gha-ts 提供了一种通过 js/ts 编写 github actions workflow 的一种方式,而且这种方式是类型安全的:
.github/workflows/test.ts
ts
#!/usr/bin/env bun
import { YAML } from 'bun'
import { checkout, setupNode } from '@jlarky/gha-ts/actions'
import { generateWorkflow } from '@jlarky/gha-ts/cli'
import { workflow } from '@jlarky/gha-ts/workflow-types'
const wf = workflow({
name: 'Testing',
on: {
workflow_dispatch: {}
},
jobs: {
Tests: {
'runs-on': 'ubuntu-latest',
steps: [
checkout({ 'fetch-depth': 0 }),
{ name: 'Setup Pnpm', uses: 'pnpm/action-setup@v4', with: { version: 'latest' } },
setupNode({ 'node-version': 'latest' }),
{ name: 'Install Deps', run: 'pnpm i' },
{ name: 'Install Playwright', run: 'pnpm exec playwright install' },
{ name: 'Build', run: 'pnpm build' },
{ name: 'Test', run: 'pnpm run test' }
]
}
}
})
await generateWorkflow(wf, YAML.stringify, import.meta.url)
在 package.json 中添加 "build:test": "bun ./.github/workflows/test",可生成:
.github/workflows/test.generated.yaml
yaml
# Do not modify!
# This file was generated by https://github.com/JLarky/gha-ts
name: Testing
"on":
workflow_dispatch:
{}
jobs:
Tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: latest
- name: Install Deps
run: pnpm i
- name: Install Playwright
run: pnpm exec playwright install
- name: Build
run: pnpm build
- name: Test
run: pnpm run test
@jlarky/gha-ts 内置了许多常用的 actions:
- setupNode
- setupBun
- setupGO
- configurePages
- deployPages
- uploadPagesArtifact
- cache
- cacheSave
- uploadArtifactMerge
- downloadArtifact
...更多 actions,请查看 actions
schema validate
ai-sdk-panel 使用 ts-json-schema-generator 将 ts 类型定义文件生成为 json schema,导出给 jsonschema 用于运行时验证用户上传导入的配置文件是否符合对应格式,这里有一个需要注意的地方,生成 json schema 时需要指定一个 schemaId,否则 jsonschema 验证时会报错:
scripts/schema.ts
ts
import { writeFileSync } from 'fs'
import { resolve } from 'path'
import tsj from 'ts-json-schema-generator'
import type { Config } from 'ts-json-schema-generator'
const cwd = process.cwd()
const input_path = resolve(`${cwd}/src/libs/Providers/types.ts`)
const output_path = resolve(`${cwd}/src/schema.json`)
const config = { path: input_path, type: 'Config', skipTypeCheck: true, schemaId: 'https://_.json' } as Config
const schema = tsj.createGenerator(config).createSchema(config.type)
const json = JSON.stringify(schema, null, 6)
writeFileSync(output_path, json)
这种提前生成 schema,运行时验证的方式避免了引入 zod 这样的运行时有一定占用的体积的库,如果应用开发,使用 zod 是最优解。
当然,god in the details,还有许多细节,虽然是个代码不多的库,也花了我连续三周每天抽一点时间出来构建,现在都流行代码全交给 AI 来生成,我却一直只让 AI 充当助手的角色,我相信coding 其实一种信息密度很高 的行为,写代码到了一定境界,其实是一种享受,享受专注于心流的状态,享受细节于技术与艺术之间的平衡。
放几张 ai-sdk-panel 的预览图:



欢迎 Star,提出使用或代码建议 👏 :
另外,我是一名拥有 7 年经验的设计工程师,做过几年商业化开源,做过一段时间独立开发,综合能力抗打,远超常人的抗压能力,现 base 成都,正在寻求远程工作/base成都的机会,VX:Mrhehero ,Email: xiewendao@gmail.com ,欢迎链接🤝。