跪拜 Guibai
← Back to the summary

GenUI SDK Drops Built-in Components — Now Any UI Kit Can Be a Material Library

Many friends have been excited after encountering the GenUI SDK's generative UI capabilities and couldn't wait to try them out. But after testing, they quickly ran into a real problem: the pages generated by generative UI look good, but once placed into their own business systems, the styles, colors, and interactions are completely mismatched and out of place with the existing product UI.

So everyone came asking: can the style of GenUI SDK be adapted to match our project's own component library style?

The answer is: yes. Since version 1.3.0, GenUI SDK has completed material decoupling — the framework no longer has built-in components, but instead accesses any component library through independent "material packages." Official material packages based on OpenTiny Vue, Element Plus, and OpenTiny NG have already been released.

Today, we will build our own material library from scratch, with the protagonist being the Naive UI component library for the Vue tech stack. Let's first preview the final result:

2.gif

Why do you need a custom GenUI material library?

The frontend component library ecosystem is very rich, with different component libraries having their own advantages, suitable for different project scenarios and development needs. Many existing projects have chosen different component libraries based on their own requirements — for example, the Naive UI we are using today, but the official materials do not yet support it.

Furthermore, different systems have different focuses on how they use the same component library: dashboard systems concentrate on chart components, while information collection scenarios frequently use form components. In these cases, a streamlined material library — only introducing the components you actually need on demand — will yield a better generation experience.

Take the Naive UI material library we are building today as an example: its purpose is to generate a login page, so it only needs simple form components and buttons. Only introducing the needed components on demand can compress the prompt size, helping both generation speed and generation quality.

This is where a custom material library comes into play.

Building a GenUI SDK material library based on Naive UI

Developing a material library is actually very simple, requiring only two "ingredients":

  1. Component list (materials): The renderer needs to translate componentName into real components
  2. Component manual (meta): The large model needs to know what components exist and what properties each component has

We will make a minimalist Naive UI material library, containing only:

Small as a sparrow, but complete in all its organs. All the secrets of the material library are hidden in these few files.

The complete demo project (including all components and test code) has been placed on GitHub: opentiny/genui-sdk-demos. What follows is a step-by-step guide.

Project Structure

Let's first reveal the overall structure; each Step will fill it in later:

genui-materials-naive-ui/
├── src/
│   ├── index.ts                    # Package entry, unified export of meta and materials
│   ├── materials/
│   │   ├── index.ts                # materials sub-path entry
│   │   ├── materials.ts            # Assemble IMaterials (component table + default value mapping)
│   │   └── components/
│   │       ├── index.ts
│   │       ├── components.ts       # componentName -> component registry
│   │       └── NIconSvg.vue        # Icon wrapper component (Step 4)
│   └── meta/
│       ├── index.ts                # meta sub-path entry
│       ├── meta.ts                 # Assemble IMaterialsMeta (protocol + whitelist)
│       ├── white-list.ts           # Whitelist of componentNames allowed for LLM use
│       └── bundle.json             # Component protocol description (LLM manual)
├── test/                           # Local test project
│   ├── main.ts
│   ├── App.vue                     # GenuiConfigProvider + GenuiRenderer integration test
│   └── fetch-schema-stream.ts      # Stream request to LLM and parse Schema
├── index.html                      # Test dev server entry
├── vite.config.ts                  # Library mode build configuration
├── vite.test.config.ts             # Local test dev server configuration
├── .env                            # LLM API address and Key (for local testing)
└── package.json

Step 1: Initialize the project

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 is the renderer, needed for local testing (placed in devDependencies, the build artifact does not depend on it).

Key fields in package.json (note the use of exports to declare the materials and meta sub-paths):

{
  "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: Component Registry (materials)

When the renderer gets the componentName from the Schema, it relies on this table to find the real component:

// 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,
};

We will register the icon component NIconSvg in Step 4; here we first register the basic form components.

Then assemble the registry into the IMaterials needed by the renderer:

// 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),
};

It's that simple. One line of component is one material.

Step 3: Component Manual (meta)

This is the most critical step — telling the large model "how the component should be used." We take NInput as an example and write its protocol description (placed in src/meta/bundle.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": "绑定值改变时触发" }
              }
            }
          }
        }
      ]
    }
  }
}

Each field corresponds to the Schema structure that the large model will ultimately generate, so it must be written clearly and accurately. Meaning of each field:

Then assemble bundle.json and the whitelist into materialsMeta:

// src/meta/white-list.ts
export const whiteList = [
  'NInput', 'NSelect', 'NButton', 'NForm', 'NFormItem', 'NCard',
  'div', 'span', 'Text',
];
// 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: [],
};

Three entry files to wrap up (recommended directory structure):

// 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: How to add icon materials?

Adding icon materials is exactly the same as adding regular components, with just one extra step: encapsulate an icon component that maps the name property to a specific icon. The official vue-element-plus material package's ElIconSvg is a ready-made template.

1. Encapsulate the icon component

Create src/materials/components/NIconSvg.vue, fetching the corresponding icon from @vicons/ionicons5 by name:

<!-- 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. Register into the component registry

// src/materials/components/components.ts
import NIconSvg from './NIconSvg.vue';

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

3. Describe it in bundle.json

An icon component is just a regular component, with only one property name. Using SelectIconConfigurator allows direct icon selection in the configuration panel:

{
  "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. Add to the whitelist

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

Now the large model can casually generate buttons with icons, for example, stuffing an NIconSvg into the icon slot of NButton.

Step 5: Build Configuration

Use Vite library mode to build materials and meta as independent entries, and keep vue and naive-ui as external:

// 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: Local Verification

After writing the material library, don't rush to publish — run it locally first to see if the LLM can actually generate a Naive UI interface. The material package includes a test/ test project, using Vite to start a separate dev server.

vite.test.config.ts starts an independent test dev server on port 5175:

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

// Used for locally verifying App.vue in the test/ directory
export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5175,
    open: true,
  },
});

Entry index.html (in the package root, script points to test/main.ts):

<!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:

import { createApp } from 'vue';
import App from './App.vue';

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

test/App.vue — the core integration page: input a question -> call LLM to generate Schema -> GenuiRenderer renders into a real Naive UI interface:

<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 material registry: component mapping (renderer uses this to resolve componentName -> component)
const materials = { components };

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

// Generate task description (system prompt) via core package, injecting material protocol and whitelist rules
const systemPrompt = genPrompt('Vue', materialsMeta);

console.log('genPrompt result (first 200 chars):', 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('Request failed:', 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 — uses PatternExtractor to parse schemaJson fragments from the LLM's streaming output:

import { PatternExtractor } from '@opentiny/genui-sdk-core';

/**
 * Send user input and the task description (systemPrompt) generated by genPrompt to the LLM,
 * and stream-parse the schemaJson fragments within.
 */
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('Failed to parse backend data:', e, dataStr);
        }
      }
    }
  } finally {
    reader.releaseLock();
  }
}

Finally, configure .env (LLM API address and Key):

VITE_DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
VITE_DEEPSEEK_API_KEY=sk-your-deepseek-api-key

Start:

npm run dev

The browser will automatically open http://localhost:5175. Enter "Help me generate a login form" and you will see the Naive UI interface generated by the LLM — the preview effect mentioned at the beginning of the article.

Step 7: Publish

And just like that, a simple material library is developed. You can even modify the package name and publish it to the npm registry:

npm run build
npm publish --access public

That's it. Your first material library is online.

Material Library Project Integration and Configuration

After the material library is developed, integrating it into an application only takes two steps.

Install:

npm install genui-materials-naive-ui naive-ui

Frontend rendering — use GenuiConfigProvider to inject materials:

<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>

Server-side generation — use genPrompt to splice materialsMeta into the system prompt:

import { genPrompt } from '@opentiny/genui-sdk-core';
import { materialsMeta } from 'genui-materials-naive-ui/meta';

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

At this point, you can let AI generate Naive UI style interfaces in conversations. This is the effect shown at the beginning of the article~

Three Practical Tips to Improve Material Library Usability

  1. Write detailed descriptions: The more specific the description of components and properties, the more accurate the LLM generation — this is the optimization with the highest cost-performance ratio.
  2. Provide example Schemas: Place a typical form in materialsMeta.examples, and the LLM will follow the pattern.
  3. Declare buffer fields: Declare field paths like [componentName=NSelect] > props > options in materials.requiredCompleteFieldSelectors for more stable streaming rendering.

AI Empowers Efficient Material Library Iteration

In addition to developing a material library by following the steps above, there is actually a "shortcut" — using AI assistance.

The specific approach:

  1. Send the official TinyVue material library to the Agent (material library address: https://github.com/opentiny/genui-sdk/tree/dev/packages/materials/vue-opentiny-vue)
  2. Also send the component documentation links of the component library you want to customize to the Agent (for example: https://www.naiveui.com/en-US/light/components/button)
  3. Tell the Agent which components you want to integrate, or describe your scenario and let the Agent figure it out
  4. Let the Agent write the material information by referencing the official TinyVue material library

This way you can quickly complete a custom material library (the material information in the demo library was generated using AI assistance).

Summary

Material decoupling means the component ecosystem of GenUI SDK is now completely open. Whether it's Element Plus, Ant Design, or your company's internal private component library, they can all be quickly integrated using this three-piece set:

Component mapping (materials) + Component manual (meta) + Injection usage (ConfigProvider / genPrompt)

The complete demo project (genui-materials-naive-ui, including all components and test code) is open-sourced on GitHub and can be directly cloned or referenced:

If you also create a useful material library, you are welcome to share your work on GitHub, or submit a PR to have it included in the official material package!

About OpenTiny NEXT

OpenTiny NEXT is an enterprise intelligent frontend development solution, based on the two core technologies of generative UI and WebMCP. It intelligently upgrades existing traditional products like the TinyVue component library and TinyEngine low-code engine, building new products such as frontend NEXT-SDKs, AI Extension, TinyRobot intelligent component library, and GenUI for Agent applications, enabling AI to understand user intent and autonomously complete tasks, accelerating the intelligent transformation of enterprise applications. Welcome to join the OpenTiny open-source community. Add the WeChat assistant: opentiny-official to participate in frontend technology discussions~ OpenTiny official website: https://opentiny.design GenUI SDK code repository: https://github.com/opentiny/genui-sdk (Welcome to star ⭐) If you also want to contribute, you can enter the code repository, find the good first issue tag, and participate in open-source contributions together~ If you have any questions, feel free to leave a comment for discussion!