DEV Community

OpenTiny
OpenTiny

Posted on

Naive UI GenUI SDK: Practical Guide for Building Custom Material Libraries

Many developers are impressed by Generative‑UI capabilities of GenUI SDK at first trial. However, they quickly hit a real‑world problem: the pages generated by GenUI look great in demos, yet they clash with styles, color themes and interaction patterns of existing business systems once integrated into your project.

This raises a frequent question: Can GenUI SDK adapt its visual style to match my project’s own component library?

Answer: Yes. Starting from v1.3.0, GenUI SDK has achieved complete material decoupling. The framework no longer ships with built‑in components; instead, arbitrary component libraries are imported via standalone material packages. Official material packages are available for OpenTiny Vue, Element Plus and OpenTiny NG.

This article walks you through building a custom material library for the Vue‑based Naive UI component library.

Why Build a Custom GenUI Material Library?

The frontend ecosystem hosts a rich variety of component libraries tailored for different project requirements. Many legacy projects adopt alternative libraries such as Naive UI, for which official GenUI materials are not yet available.

Furthermore, different systems prioritize distinct component sets: dashboard projects make heavy use of chart components, while data‑collection scenarios rely mostly on form widgets. In such cases, a trimmed‑down material library containing only components you actually need yields better generation quality and reduces prompt token consumption.

For our Naive‑UI example targeting login‑page generation, only simple form widgets and buttons are required. Limiting the exposed component scope shrinks prompt payload and improves both generation speed and output accuracy — exactly where custom material libraries add value.

Building a GenUI SDK Material Library for Naive UI

Creating a material library requires two core artifacts:

  1. materials (Component Registry): Maps componentName from Schema to real runtime components.
  2. meta (Component Specification / Metadata): Tells the LLM what components exist and what properties each component accepts.

Our minimal Naive‑UI material library will contain:

  • Form components: NInput, NSelect, NButton
  • Form containers: NForm, NFormItem
  • Card container: NCard (default wrapper component)
  • Icons: Encapsulated NIconSvg (covered in Step 4), plus two icons: SearchOutline, CheckmarkOutline

Though compact, this set covers essential capabilities. Full demo source code is available in GitHub: opentiny/genui-sdk-demos. Below is a hands‑on step‑by‑step tutorial.

Project Structure


genui-materials-naive-ui/
├── src/
│   ├── index.ts                 # Package entry, exports meta & materials
│   ├── materials/
│   │   ├── index.ts             # materials sub‑path entry
│   │   ├── materials.ts         # Assemble IMaterials (component table + default‑value map)
│   │   └── components/
│   │       ├── index.ts
│   │       ├── components.ts    # componentName → real‑component registry
│   │       └── NIconSvg.vue     # Encapsulated icon component (Step 4)
│   └── meta/
│       ├── index.ts             # meta sub‑path entry
│       ├── meta.ts              # Assemble IMaterialsMeta (protocol + allow‑list)
│       ├── white-list.ts        # componentName allow‑list for LLM usage
│       └── bundle.json          # Component protocol descriptions (LLM specs)
├── test/                        # Local test project
│   ├── main.ts
│   ├── App.vue                  # GenuiConfigProvider + GenuiRenderer integration test
│   └── fetch-schema-stream.ts   # Stream LLM responses and parse Schema
├── index.html                   # Dev‑server entry page
├── vite.config.ts               # Library‑mode build config
├── vite.test.config.ts          # Local dev‑server config for testing
├── .env                         # LLM endpoint & API‑key for local development
└── package.json

Enter fullscreen mode Exit fullscreen mode

Step 1: Initialize 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
Enter fullscreen mode Exit fullscreen mode

Note: @opentiny/genui-sdk-vue is used only for local testing and belongs in devDependencies, not production build artifacts.

package.json key fields (declare sub‑path exports for materials and meta):

{
  "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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Component Registry (materials)

The renderer looks up real components by componentName via this registry.

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,
};
Enter fullscreen mode Exit fullscreen mode

Icon component NIconSvg will be registered later in Step 4.

Assemble into the IMaterials structure expected 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: string[] = [];

export { components };

export const materials: IMaterials = {
  components,
  requiredCompleteFieldSelectors,
  defaultPropsMap: buildMaterialDefaultValueMap(materialsMeta),
};
Enter fullscreen mode Exit fullscreen mode
  • requiredCompleteFieldSelectors: Configures buffered fields rendered only after fully resolved.
  • buildMaterialDefaultValueMap: Generates default‑property map from bundle.json for graceful fallback during streaming rendering.

Step 3: Component Metadata Specification (meta)

This is the critical part: teaching the LLM how to use each component. Example for NInput in src/meta/bundle.json:

{
  "data": {
    "framework": "Vue",
    "materials": {
      "components": [
        {
          "name": {
            "zh_CN": "Input"
          },
          "component": "NInput",
          "description": "Accept character input via mouse or keyboard",
          "npm": {
            "package": "naive-ui",
            "exportName": "NInput",
            "destructuring": true
          },
          "schema": {
            "properties": [
              {
                "name": "0",
                "label": {
                  "zh_CN": "Basic Properties"
                },
                "content": [
                  {
                    "property": "modelValue",
                    "label": {
                      "text": {
                        "zh_CN": "Bound Value"
                      }
                    },
                    "description": {
                      "zh_CN": "Two‑way bound input value"
                    },
                    "required": true,
                    "type": "string",
                    "cols": 12
                  },
                  {
                    "property": "placeholder",
                    "label": {
                      "text": {
                        "zh_CN": "Placeholder Text"
                      }
                    },
                    "description": {
                      "zh_CN": "Input box placeholder hint"
                    },
                    "required": false,
                    "type": "string",
                    "cols": 12
                  }
                ]
              }
            ],
            "events": {
              "onUpdate:modelValue": {
                "label": {
                  "zh_CN": "Triggered when bound value changes"
                },
                "description": {
                  "zh_CN": "Fires on modification of bound input value"
                }
              }
            }
          }
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Field explanations:

  • component: Component identifier, must exactly match keys inside component registry.
  • name: Human‑readable display name for configuration panels.
  • description: Component description consumed by LLM prompt generation.
  • npm: Package metadata for import/code generation.
  • schema.properties: Grouped property definitions for configuration panels.
  • schema.events: Declare events available for LLM‑generated event bindings.

Allow‑list src/meta/white-list.ts

export const whiteList = [
  'NInput',
  'NSelect',
  'NButton',
  'NForm',
  'NFormItem',
  'NCard',
  'div',
  'span',
  'Text',
];
Enter fullscreen mode Exit fullscreen mode

Assemble metadata object 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: [],
};
Enter fullscreen mode Exit fullscreen mode

Export entry files:
src/materials/components/index.ts

export * from './components';
Enter fullscreen mode Exit fullscreen mode

src/materials/index.ts

export * from './materials';
Enter fullscreen mode Exit fullscreen mode

src/meta/index.ts

export * from './meta';
Enter fullscreen mode Exit fullscreen mode

src/index.ts

export * from './meta';
export * from './materials';
Enter fullscreen mode Exit fullscreen mode

Step 4: Adding Icon Materials

Icon components follow the same pattern as ordinary widgets, with one extra step: wrap an icon wrapper component mapping name prop to concrete icon assets. Refer to ElIconSvg from the official vue‑element‑plus material package.

  1. Create icon wrapper component 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>
Enter fullscreen mode Exit fullscreen mode
  1. Register inside component registry src/materials/components/components.ts
import NIconSvg from './NIconSvg.vue';

export const components: IComponents = {
  NButton,
  NCard,
  NForm,
  NFormItem,
  NIconSvg,
  NInput,
  NSelect,
};
Enter fullscreen mode Exit fullscreen mode
  1. Add component definition inside bundle.json
{
  "name": { "zh_CN": "Icon" },
  "component": "NIconSvg",
  "description": "Icon component; set name property e.g. SearchOutline, CheckmarkOutline",
  "schema": {
    "properties": [
      {
        "name": "0",
        "label": { "zh_CN": "Basic Properties" },
        "content": [
          {
            "property": "name",
            "label": { "text": { "zh_CN": "Icon Name" } },
            "description": { "zh_CN": "Icon identifier such as SearchOutline (search), CheckmarkOutline (checkmark)" },
            "required": true,
            "type": "string",
            "cols": 12,
            "widget": { "component": "SelectIconConfigurator", "props": {} }
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Append component name to white-list.ts
export const whiteList = [
  'NInput',
  'NSelect',
  'NButton',
  'NForm',
  'NFormItem',
  'NCard',
  'NIconSvg',
  'div',
  'span',
  'Text',
];
Enter fullscreen mode Exit fullscreen mode

Now the LLM can generate buttons with icons, e.g. place NIconSvg inside button icon slots.

Step 5: Build Configuration

Vite library‑mode build, export separate entry‑points for materials and meta, mark vue / naive‑ui as external dependencies.

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 || {})],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Step 6: Local Validation

Before publishing, test locally to verify the LLM can generate valid Naive‑UI interfaces. The test/ directory contains a separate dev‑server setup.

vite.test.config.ts (local test server, port 5175)

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5175,
    open: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

Root‑level index.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>
Enter fullscreen mode Exit fullscreen mode

test/main.ts

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

createApp(App).mount('#app');
Enter fullscreen mode Exit fullscreen mode

test/App.vue (core integration page: user input → LLM schema → GenuiRenderer renders Naive‑UI UI)

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

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

const systemPrompt = genPrompt('Vue', materialsMeta);
console.log('genPrompt output (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 vmodel="inputText" placeholder="Enter prompt, e.g.: generate a login form" @keyup.enter="handleSend" />
        <button :disabled="generating" @click="handleSend">{{ generating ? 'Generating…' : 'Send' }}</button>
      </div>
      <GenuiRenderer :content="schema" :key="rendererKey" />
    </div>
  </GenuiConfigProvider>
</template>

<style scoped>
.democontainer { padding: 16px; box‑sizing: borderbox; }
.inputgroup { 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: notallowed; }
</style>
Enter fullscreen mode Exit fullscreen mode

test/fetch‑schema‑stream.ts — uses PatternExtractor to parse schemaJson chunks from streamed LLM responses

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

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('Parse backend data failed:', e, dataStr);
        }
      }
    }
  } finally {
    reader.releaseLock();
  }
}
Enter fullscreen mode Exit fullscreen mode

Create .env file for LLM endpoint and credentials

VITE_DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
VITE_DEEPSEEK_API_KEY=skyourdeepseekapikey
Enter fullscreen mode Exit fullscreen mode

Launch local dev server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Browser opens http://localhost:5175. Input prompt such as generate a login form and observe Naive‑UI components rendered from LLM output.

Step 7: Publish

Once local testing passes you may rename the package and publish to npm registry.

npm run build
npm publish --access public
Enter fullscreen mode Exit fullscreen mode

Integrating the Material Library Into an Application

Two‑step integration after building your custom material package.

Install dependency

npm install genui‑materials‑naive‑ui naive‑ui
Enter fullscreen mode Exit fullscreen mode

Front‑end rendering: inject materials via GenuiConfigProvider

<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>
Enter fullscreen mode Exit fullscreen mode

Server‑side prompt generation: feed materialsMeta into genPrompt

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

const systemPrompt = genPrompt('Vue', materialsMeta);
Enter fullscreen mode Exit fullscreen mode

You can now instruct the LLM within chat sessions to generate UI following Naive‑UI styling.

Three Practical Tips To Improve Material‑Library Quality

  1. Write thorough descriptions: Detailed description fields for components and properties deliver the highest return on investment for improved LLM output accuracy.
  2. Supply example schemas: Populate materialsMeta.examples with representative form schemas as reference templates for the LLM.
  3. Declare buffered fields: Add field‑path rules such as [componentName=NSelect] > props > options inside materials.requiredCompleteFieldSelectors for more robust streaming rendering.

Accelerating Material‑Library Development With AI

Besides manual implementation you can leverage AI as a shortcut workflow:

  1. Supply the official TinyVue material‑library repository to your Agent: https://github.com/opentiny/genui-sdk/tree/dev/packages/materials/vue‑opentiny‑vue
  2. Provide component‑documentation links for your target library (e.g. Naive‑UI Button docs: https://www.naiveui.com/en‑US/light/components/button)
  3. Tell the Agent which components you intend to include, or describe your business scenarios for automatic component discovery.
  4. Ask the Agent to generate material metadata by referencing the official TinyVue material specs.

This approach drastically speeds up custom material‑library construction (the demo material‑library metadata was generated with AI assistance).

Summary

Material decoupling opens GenUI‑SDK’s component ecosystem to third‑party contributions. Whether you adopt Element Plus, Ant Design or internal private component libraries, integration follows this three‑part pattern:

Component mapping (materials) + Component specification metadata (meta) + Runtime injection (ConfigProvider / genPrompt)

Full demo repository: https://github.com/opentiny/genui-sdk-demos/pull/1

If you build useful custom material libraries, feel free to share your work on GitHub or submit PRs for inclusion within official GenUI‑SDK materials.

About OpenTiny NEXT

OpenTiny NEXT is an enterprise‑grade intelligent frontend development solution built on Generative UI and WebMCP. It delivers intelligent upgrades for legacy products including the TinyVue component library and TinyEngine low‑code engine, while launching Agent‑oriented products such as NEXT‑SDKs for frontend, AI Extension, TinyRobot AI Assistant and GenUI. It enables AI to interpret user intentions and autonomously execute tasks, accelerating intelligent transformation of enterprise applications.

Join the OpenTiny Open‑Source Community

WeChat Assistant: opentiny‑official

If you want to contribute, look for issues tagged good first issue in repositories. Feel free to leave comments for questions and feedback!

Top comments (0)