Naive UI × GenUI SDK:自定义物料库搭建实战

不少朋友在接触到 GenUI SDK 生成式 UI 能力之后都眼前一亮,迫不及待地去尝鲜。但试用完很快就遇到了一个现实问题:生成式 UI 生成出来的页面看着效果不错,可一放到自己的业务系统里,样式、色调、交互全都对不上,和现有产品 UI 格格不入。

于是大家纷纷来问:能不能让 GenUI SDK 的样式风格,适配我们项目自己的组件库风格?

答案是:可以。自 1.3.0 起,GenUI SDK 完成了物料解耦------框架不再内置组件,而是通过独立的「物料包」接入任意组件库。官方已发布基于 OpenTiny Vue、Element Plus、OpenTiny NG 的物料包。

今天,我们就来亲手打造一个属于自己的物料库,主角是 Vue 技术栈的 Naive UI 组件库。先来预览一下最终成果:

为什么需要自定义 GenUI 物料库?

前端组件库生态十分丰富,不同组件库各有优势,适配不同的项目场景与开发需求。很多存量项目都根据自身需求选用了不同的组件库------比如今天要用的 Naive UI,但官方物料暂时并不支持它。

更进一步说,不同的系统对同一个组件库的使用方式也各有侧重:看板系统集中使用图表组件,信息收集场景频繁使用表单组件。这时候,精简物料库------只按需引入你真正需要的组件------会获得更好的生成体验。

拿今天要制作的 Naive UI 物料库举例:它的用途是生成一个登录页面,因此只需要简单的表单组件和按钮就够用了。仅按需引入需要的组件,可以压缩提示词的大小,对生成速度和生成效果都有帮助。

这时候,自定义物料库就派上用场了。

基于 Naive UI 搭建 GenUI SDK 物料库

开发一个物料库其实十分简单,只需要准备两份"材料":

  1. 组件清单(materials) :渲染器要把 componentName 翻译成真实组件
  2. 组件说明书(meta):大模型要知道有哪些组件、每个组件有哪些属性

我们要做一个极简的 Naive UI 物料库,只包含:

  • 表单组件:NInput 输入框、NSelect 选择器、NButton 按钮
  • 表单容器:NForm 表单、NFormItem 表单项
  • 卡片容器:NCard(也是默认的包裹组件)
  • 图标:自封装的 NIconSvg(Step 4 讲解),搭配 2 个图标------放大镜 SearchOutline、对勾 CheckmarkOutline

麻雀虽小,五脏俱全。物料库的全部秘密,也就是就藏在这几个文件里。

完整 demo 工程(含全部组件与测试代码)已放到 GitHub:opentiny/genui-sdk-demos。接下来就是手把手的指导过程。

项目结构

先来剧透一下整体结构,后面每个 Step 会逐个填进去:

bash 复制代码
genui-materials-naive-ui/
├── src/
│   ├── index.ts                    # 包入口,统一导出 meta 与 materials
│   ├── materials/
│   │   ├── index.ts                # materials 子路径入口
│   │   ├── materials.ts            # 组装 IMaterials(组件表 + 默认值映射)
│   │   └── components/
│   │       ├── index.ts
│   │       ├── components.ts       # componentName -> 组件 注册表
│   │       └── NIconSvg.vue        # 图标封装组件(Step 4)
│   └── meta/
│       ├── index.ts                # meta 子路径入口
│       ├── meta.ts                 # 组装 IMaterialsMeta(协议 + 白名单)
│       ├── white-list.ts           # 允许 LLM 使用的 componentName 白名单
│       └── bundle.json             # 组件协议描述(LLM 说明书)
├── test/                           # 本地测试工程
│   ├── main.ts
│   ├── App.vue                     # GenuiConfigProvider + GenuiRenderer 联调
│   └── fetch-schema-stream.ts      # 流式请求 LLM 并解析 Schema
├── index.html                      # 测试 dev server 入口
├── vite.config.ts                  # 库模式构建配置
├── vite.test.config.ts             # 本地测试 dev server 配置
├── .env                            # LLM 接口地址与 Key(本地测试用)
└── package.json

Step 1:初始化项目

bash 复制代码
mkdir genui-materials-naive-ui && cd genui-materials-naive-ui
npm init -y
npm install @opentiny/genui-sdk-core
npm install vue naive-ui @vicons/ionicons5
npm install -D typescript vite vite-plugin-dts @vitejs/plugin-vue @opentiny/genui-sdk-vue

@opentiny/genui-sdk-vue 是渲染器,本地测试时要用到(放在 devDependencies,构建产物不依赖它)。

package.json 关键字段(注意用 exports 声明 materialsmeta 两个子路径):

json 复制代码
{
  "name": "genui-materials-naive-ui",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".":          { "types": "./dist/index.d.ts",  "import": "./dist/index.js" },
    "./materials":{ "types": "./dist/materials.d.ts", "import": "./dist/materials.js" },
    "./meta":     { "types": "./dist/meta.d.ts",   "import": "./dist/meta.js" }
  },
  "scripts": {
    "build": "vite build",
    "dev": "vite --config vite.test.config.ts"
  },
  "dependencies": {
    "@opentiny/genui-sdk-core": "^1.3.0",
    "@vicons/ionicons5": "^0.13.0",
    "naive-ui": "^2.45.0",
    "vue": "^3.5.32"
  },
  "devDependencies": {
    "@opentiny/genui-sdk-vue": "^1.3.0",
    "@vitejs/plugin-vue": "^6.0.6",
    "typescript": "~5.9.3",
    "vite": "^8.0.8",
    "vite-plugin-dts": "^5.0.3"
  }
}

Step 2:组件注册表(materials)

渲染器拿到 Schema 里的 componentName,就要靠这张表找到真实组件:

ts 复制代码
// src/materials/components/components.ts
import type { Component } from 'vue';
import { NButton, NCard, NForm, NFormItem, NInput, NSelect } from 'naive-ui';

export interface IComponents {
  [key: string]: Component;
}

export const components: IComponents = {
  NButton,
  NCard,
  NForm,
  NFormItem,
  NInput,
  NSelect,
};

图标组件 NIconSvg 我们放到 Step 4 再注册,这里先注册基础表单组件。

再把注册表组装成渲染器需要的 IMaterials

  • requiredCompleteFieldSelectors 可以配置缓冲字段,让特殊字段在完整后再显示。
  • buildMaterialDefaultValueMap 会根据 bundle.json 里的属性生成默认值映射,流式渲染时字段不全也能自动补全。
ts 复制代码
// src/materials/materials.ts
import { buildMaterialDefaultValueMap, type IMaterials } from '@opentiny/genui-sdk-core';
import { materialsMeta } from '../meta';
import { components } from './components';

const requiredCompleteFieldSelectors = [];

export { components };

export const materials: IMaterials = {
  components,
  requiredCompleteFieldSelectors,
  defaultPropsMap: buildMaterialDefaultValueMap(materialsMeta),
};

就这么简单。一行组件,就是一个物料。

Step 3:组件说明书(meta)

这是最关键的一步------告诉大模型「组件应该怎么用」。我们以 NInput 为例,写它的协议描述(放在 src/meta/bundle.json):

json 复制代码
{
  "data": {
    "framework": "Vue",
    "materials": {
      "components": [
        {
          "name": { "zh_CN": "输入框" },
          "component": "NInput",
          "description": "通过鼠标或键盘输入字符",
          "npm": {
            "package": "naive-ui",
            "exportName": "NInput",
            "destructuring": true
          },
          "schema": {
            "properties": [
              {
                "name": "0",
                "label": { "zh_CN": "基础属性" },
                "content": [
                  {
                    "property": "modelValue",
                    "label": { "text": { "zh_CN": "绑定值" } },
                    "description": { "zh_CN": "绑定值" },
                    "required": true,
                    "type": "string",
                    "cols": 12
                  },
                  {
                    "property": "placeholder",
                    "label": { "text": { "zh_CN": "占位文本" } },
                    "description": { "zh_CN": "输入框占位文本" },
                    "required": false,
                    "type": "string",
                    "cols": 12
                  }
                ]
              }
            ],
            "events": {
              "onUpdate:modelValue": {
                "label": { "zh_CN": "绑定值改变时触发" },
                "description": { "zh_CN": "绑定值改变时触发" }
              }
            }
          }
        }
      ]
    }
  }
}

每个字段都对应大模型最终要生成的 Schema 结构,所以要写清楚、写准确。各字段含义:

  • component:组件名,对应渲染时的 componentName,必须与组件注册表的 key 一致。
  • name:组件显示名,用于配置面板与分组展示。
  • description:组件说明,LLM 生成时的主要依据,写得越细越准。
  • npm:组件来源包信息(包名、导出名),用于代码生成与按需引入。
  • schema.properties:属性分组描述,决定配置面板展示哪些属性。
  • schema.events:组件事件,供 LLM 生成事件绑定。

然后把 bundle.json 和白名单组装成 materialsMeta

ts 复制代码
// src/meta/white-list.ts
export const whiteList = [
  'NInput', 'NSelect', 'NButton', 'NForm', 'NFormItem', 'NCard',
  'div', 'span', 'Text',
];
ts 复制代码
// src/meta/meta.ts
import type { IMaterialsMeta, IMaterialsProtocol } from '@opentiny/genui-sdk-core';
import bundleJson from './bundle.json' with { type: 'json' };
import { whiteList } from './white-list';

export const materialsMeta: IMaterialsMeta = {
  materials: [bundleJson] as unknown as IMaterialsProtocol[],
  wrapperComponent: 'NCard',
  whiteList,
  examples: [],
  rules: [],
};

三个入口文件收尾(推荐的目录结构):

ts 复制代码
// src/materials/components/index.ts
export * from './components';

// src/materials/index.ts
export * from './materials';

// src/meta/index.ts
export * from './meta';

// src/index.ts
export * from './meta';
export * from './materials';

Step 4:图标物料怎么加?

加图标物料其实和加普通组件完全一样,只多一步:封装一个图标组件,把 name 属性映射到具体图标。官方 vue-element-plus 物料包的 ElIconSvg 就是个现成范本。

1. 封装图标组件

新建 src/materials/components/NIconSvg.vue,按 name@vicons/ionicons5 里取对应图标:

vue 复制代码
<!-- src/materials/components/NIconSvg.vue -->
<script setup lang="ts">
import { computed, type Component } from 'vue';
import * as Icons from '@vicons/ionicons5';

const props = withDefaults(
  defineProps<{
    name: string;
  }>(),
  { name: '' },
);

const iconComponent = computed(() => {
  return (Icons as Record<string, Component | unknown>)[props.name] || null;
});
</script>

<template>
  <component :is="iconComponent" v-if="iconComponent" />
</template>

2. 注册进组件注册表

ts 复制代码
// src/materials/components/components.ts
import NIconSvg from './NIconSvg.vue';

export const components: IComponents = {
  NButton,
  NCard,
  NForm,
  NFormItem,
  NIconSvg,
  NInput,
  NSelect,
};

3. 在 bundle.json 里描述它

图标组件就是个普通组件,属性只有一个 name,用 SelectIconConfigurator 就能在配置面板里直接选图标:

json 复制代码
{
  "name": { "zh_CN": "图标" },
  "component": "NIconSvg",
  "description": "图标组件,name 为图标名,例如 SearchOutline、CheckmarkOutline",
  "schema": {
    "properties": [
      {
        "name": "0",
        "label": { "zh_CN": "基础属性" },
        "content": [
          {
            "property": "name",
            "label": { "text": { "zh_CN": "图标名称" } },
            "description": { "zh_CN": "图标名称,例如 SearchOutline(搜索)、CheckmarkOutline(对勾)" },
            "required": true,
            "type": "string",
            "cols": 12,
            "widget": { "component": "SelectIconConfigurator", "props": {} }
          }
        ]
      }
    ]
  }
}

4. 加入白名单

ts 复制代码
// src/meta/white-list.ts
export const whiteList = [
  'NInput', 'NSelect', 'NButton', 'NForm', 'NFormItem', 'NCard', 'NIconSvg',
  'div', 'span', 'Text',
];

这样大模型就能随手生成带图标的按钮了,比如 NButton 的 icon 插槽里塞一个 NIconSvg

Step 5:构建配置

用 Vite 库模式,把 materialsmeta 打成独立入口,并让 vuenaive-ui 保持 external:

ts 复制代码
// vite.config.ts
import path from 'node:path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import vue from '@vitejs/plugin-vue';
import packageJson from './package.json';

export default defineConfig({
  plugins: [vue(), dts()],
  build: {
    lib: {
      entry: {
        index: path.resolve(__dirname, './src/index.ts'),
        materials: path.resolve(__dirname, './src/materials/index.ts'),
        meta: path.resolve(__dirname, './src/meta/index.ts'),
      },
      formats: ['es'],
      fileName: (_, entryName) => `${entryName}.js`,
    },
    sourcemap: true,
    rollupOptions: {
      external: [
        ...Object.keys(packageJson.dependencies || {}),
      ],
    },
  },
});

Step 6:本地验证

写完了物料库,先别急着发版------在本地把它跑起来,看看 LLM 能不能真的生成 Naive UI 的界面。物料包里带一个 test/ 测试工程,用 Vite 单独起一个 dev server。

vite.test.config.ts 起一个独立的测试 dev server,端口 5175:

ts 复制代码
// vite.test.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

// 用于本地验证 test/ 目录下的 App.vue
export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5175,
    open: true,
  },
});

入口 index.html(在包根目录,脚本指向 test/main.ts):

html 复制代码
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>genui-materials-naive-ui · Test</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/test/main.ts"></script>
  </body>
</html>

test/main.ts

ts 复制代码
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');

test/App.vue------核心联调页面:输入问题 -> 调 LLM 生成 Schema -> GenuiRenderer 渲染成真实的 Naive UI 界面:

vue 复制代码
<script setup lang="ts">
import { ref } from 'vue';
import { GenuiRenderer, GenuiConfigProvider } from '@opentiny/genui-sdk-vue';
import { genPrompt } from '@opentiny/genui-sdk-core';
import { components } from '../src/materials';
import { materialsMeta } from '../src/meta';
import { fetchSchemaStream } from './fetch-schema-stream';

// naive-ui 物料注册表:组件映射(渲染器通过它解析 componentName -> 组件)
const materials = { components };

const inputText = ref('');
const schema = ref<any>({ componentName: 'Page', children: [] });
const rendererKey = ref(0);
const generating = ref(false);

// 通过 core 包生成任务说明(system prompt),注入物料协议与白名单规则
const systemPrompt = genPrompt('Vue', materialsMeta);

console.log('genPrompt 生成结果(前 200 字符):', systemPrompt.slice(0, 200));

const handleSend = async () => {
  if (!inputText.value.trim() || generating.value) return;

  generating.value = true;
  schema.value = '';
  rendererKey.value++;
  const userInput = inputText.value;
  inputText.value = '';

  try {
    await fetchSchemaStream(
      import.meta.env.VITE_DEEPSEEK_API_URL,
      import.meta.env.VITE_DEEPSEEK_API_KEY,
      userInput,
      systemPrompt,
      (schemaChunk) => {
        schema.value += schemaChunk;
      },
    );
  } catch (error) {
    console.error('请求失败:', error);
  } finally {
    generating.value = false;
  }
};
</script>

<template>
  <GenuiConfigProvider :materials="materials">
    <div class="demo-container">
      <div class="input-group">
        <input
          v-model="inputText"
          placeholder="请输入问题,例如:帮我生成一个登录表单"
          @keyup.enter="handleSend"
        />
        <button :disabled="generating" @click="handleSend">{{ generating ? '生成中...' : '发送' }}</button>
      </div>
      <GenuiRenderer :content="schema" :key="rendererKey" />
    </div>
  </GenuiConfigProvider>
</template>

<style scoped>
.demo-container {
  padding: 16px;
  box-sizing: border-box;
}

.input-group {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

input {
  flex: 1;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

button {
  padding: 8px 16px;
  background: #1890ff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
</style>

test/fetch-schema-stream.ts------用 PatternExtractor 从 LLM 的流式输出里解析出 schemaJson 片段:

ts 复制代码
import { PatternExtractor } from '@opentiny/genui-sdk-core';

/**
 * 将用户输入与 genPrompt 生成的任务说明(systemPrompt)发送到 LLM,
 * 并流式解析其中的 schemaJson 片段。
 */
export async function fetchSchemaStream(
  url: string,
  apiKey: string,
  userInput: string,
  systemPrompt: string,
  onSchemaUpdate: (schemaChunk: string) => void,
): Promise<void> {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: userInput },
      ],
      model: 'deepseek-v4-flash',
      thinking: {
        type: 'disabled',
      },
      stream: true,
    }),
  });

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const reader = response.body!.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';

  const patternExtractor = new PatternExtractor({
    onNormalWrite: () => {},
    onHandledWrite: (value) => onSchemaUpdate(value),
  });

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });

      while (true) {
        const lineEndIndex = buffer.indexOf('\n');
        if (lineEndIndex === -1) break;

        const line = buffer.slice(0, lineEndIndex).trim();
        buffer = buffer.slice(lineEndIndex + 1);

        if (!line.startsWith('data:')) continue;

        const dataStr = line.slice(5).trim();

        if (dataStr === '[DONE]') {
          return;
        }

        try {
          const chunk = JSON.parse(dataStr);
          const content = chunk.choices?.[0]?.delta?.content;

          if (!content) continue;

          patternExtractor.handleContent(content);
        } catch (e) {
          console.error('解析后端数据失败:', e, dataStr);
        }
      }
    }
  } finally {
    reader.releaseLock();
  }
}

最后配一下 .env(LLM 的接口地址和 Key):

ini 复制代码
VITE_DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
VITE_DEEPSEEK_API_KEY=sk-your-deepseek-api-key

启动:

bash 复制代码
npm run dev

浏览器会自动打开 http://localhost:5175,输入「帮我生成一个登录表单」,就能看到 LLM 生成的 Naive UI 界面了------也就是文章开头提到的预览效果。

Step 7:发布

就这样,一个简单的物料库就开发完毕了。你甚至可以修改包名,然后把它发布到 npm 仓上:

bash 复制代码
npm run build
npm publish --access public

就这样。你的第一个物料库,上线了。

物料库项目接入与配置

物料库开发完成后,接入应用只需两步。

安装:

bash 复制代码
npm install genui-materials-naive-ui naive-ui

前端渲染 ------用 GenuiConfigProvider 注入 materials

vue 复制代码
<script setup lang="ts">
import { GenuiChat, GenuiConfigProvider } from '@opentiny/genui-sdk-vue';
import { materials } from 'genui-materials-naive-ui/materials';
</script>

<template>
  <GenuiConfigProvider :materials="materials">
    <GenuiChat />
  </GenuiConfigProvider>
</template>

服务端生成 ------用 genPromptmaterialsMeta 拼进系统提示词:

ts 复制代码
import { genPrompt } from '@opentiny/genui-sdk-core';
import { materialsMeta } from 'genui-materials-naive-ui/meta';

const systemPrompt = genPrompt('Vue', materialsMeta);

至此,你就能在对话里让 AI 生成 Naive UI 风格的界面了。也就是文章开篇的效果~

提升物料库可用性的三个实用技巧

  1. 把描述写详细 :组件和属性的 description 越具体,LLM 生成越准确,这是性价比最高的优化。
  2. 提供示例 Schema :在 materialsMeta.examples 里放一个典型表单,LLM 会照葫芦画瓢。
  3. 声明缓冲字段 :在 materials.requiredCompleteFieldSelectors 里声明 [componentName=NSelect] > props > options 这类字段路径,流式渲染更稳。

AI 赋能物料库高效迭代

除了按照上述步骤自行开发物料库外,其实还有一条"捷径"------使用 AI 助力。

具体做法:

  1. 将官方的 TinyVue 物料库发送给 Agent(物料库地址:github.com/opentiny/ge...
  2. 将你想要自定义的物料库的组件文档链接也发送给 Agent(例如:www.naiveui.com/en-US/light...
  3. 把你想要集成的组件告诉 Agent,或者把你的场景描述一下,让 Agent 自行梳理
  4. 让 Agent 参考官方的 TinyVue 物料库编写物料信息

这样你就可以很快地完成一个自定义物料库了(demo 库中的物料信息就是使用 AI 帮忙生成的)。

总结

物料解耦,意味着 GenUI SDK 的组件生态从此完全开放。无论是 Element Plus、Ant Design,还是你公司内部的私有组件库,都可以用这套三件套快速接入:

组件映射(materials)+ 组件说明书(meta)+ 注入使用(ConfigProvider / genPrompt)

完整的 demo 工程(genui-materials-naive-ui,含全部组件与测试代码)已在 GitHub 开源,可以直接克隆或参考:

如果你也做出了好用的物料库,欢迎在 GitHub 分享你的作品,或者提交 PR 把它收录进官方物料包!

关于OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能组件库、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design

GenUI SDK 代码仓库:github.com/opentiny/ge... (欢迎star ⭐)

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

相关推荐
kyriewen1 小时前
我拿 4 个真实前端任务试了 GLM-5.3 Flash:代码一遍跑通,账单 4 分钱
前端·程序员·ai编程
IT_陈寒1 小时前
Vue的响应式更新有时候真的不听话
前端·人工智能·后端
zhangfeng11331 小时前
AMD Instinct MI50(gfx906)上为 Qwen 系列模型优化并可用的 vLLM 相关仓库、Docker 镜像与实践指南。
人工智能·docker·ai编程·qwen·算子开发·vllm·mi50
风骏时光牛马1 小时前
AI编程常见问题梳理与要点解析
前端
前端snow1 小时前
ai agent -- LCEL 汇总
前端
机构师2 小时前
AI编程实战:效率与成本,AI 编程的 ROI 怎么算
人工智能·prompt·ai编程·deepseek
Flynt2 小时前
上周我在OpenRouter上用了个匿名模型,结果账单告诉我它是国产的
llm·ai编程·chatglm (智谱)
计算机魔术师2 小时前
NVIDIA 季度营收指引达 1080 亿美元,首次突破单季千亿大关
前端
京东云开发者2 小时前
上游给空、下游拿到 -1,我把故障一直追到了 commons-beanutils 的构造函数
java·ai编程