普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月23日首页

电商系统集成GenUI SDK实操指南

2026年4月23日 14:38

本文由云软件体验技术团队岑灌铭原创。

简介:本文以智能导购助手场景为例,带大家实操体验生成式UI带来的全新交互方式。

1. 集成目标

在电商前端中加入一个「AI 导购助手」,能力包含:

  • 通过 GenuiChat 展示对话与生成式 UI 内容
  • 通过 MCP 工具实时搜索商品
  • 渲染自定义商品卡片组件
  • 支持 AI 触发交互动作:加购、跳转商品详情、跳转购物车

2. 前置准备

2.1 环境要求

  • Node.js 18+
  • pnpm 10+

2.2 安装项目依赖

在仓库根目录执行:

pnpm install

2.3 启动 GenUI 后端服务

  1. 进入 server 目录,复制环境变量文件:
cd server
cp .env.example .env
  1. 编辑 server/.env,至少配置:
  • API_KEY:你的模型服务 Key
  • BASE_URL:模型服务地址(OpenAI 兼容接口)
  • PORT:服务端口(默认 3100
  1. 回到仓库根目录启动服务:
pnpm dev:server

运行成功后就可以看到控制台输出了genui-sdk-server is running on http://localhost:3100

大模型对话接口为 http://localhost:3100/chat/completions

至此,后台服务就准备完成了,下面来进行前端的改造。

3. 前端安装 GenUI 相关依赖

packages/e-commerce 中需要这些依赖:

  • @opentiny/genui-sdk-vue
  • @modelcontextprotocol/sdk
  • zod
  • openai

可执行:

pnpm -F e-commerce add @opentiny/genui-sdk-vue @modelcontextprotocol/sdk openai zod

4. 集成GenuiChat:生成式UI初体验

先做最小可体验版本:只要能看到聊天框、发送消息后,能接受到大模型返回就算成功。

4.1 新建 AI 对话助手

创建 src/components/AIAssistantDrawer.vue, 代码如下:

<script setup lang="ts">
import { computed, ref, type ComponentPublicInstance } from 'vue'
import { GenuiChat, GenuiConfigProvider } from '@opentiny/genui-sdk-vue'

const props = defineProps<{
  modelValue: boolean
}>()

const emit = defineEmits<{
  (e: 'update:modelValue', value: boolean): void
}>()

type GenuiChatExposed = ComponentPublicInstance & {
  handleNewConversation: () => void
}

const chatRef = ref<GenuiChatExposed | null>(null)
const theme = ref<'dark' | 'lite' | 'light'>('light')
const model = ref('deepseek-v3.2')
const temperature = ref(0)

const chatConfig = {
  addToolCallContext: false,
  showThinkingResult: true,
}

const chatUrl = 'http://localhost:3100/chat/completions'

function closeDrawer() {
  emit('update:modelValue', false)
}

function startNewConversation() {
  chatRef.value?.handleNewConversation()
}

</script>

<template>
  <Teleport to="body">
    <Transition name="drawer-fade">
      <div v-if="modelValue" class="assistant-layer" @click.self="closeDrawer">
        <aside class="assistant-drawer" aria-label="AI 导购助手">
          <header class="assistant-drawer__header">
            <div>
              <h2>AI 导购助手</h2>
            </div>
            <div class="assistant-drawer__actions" role="toolbar" aria-label="助手操作">
              <button type="button" class="assistant-drawer__action" @click="startNewConversation">
                新建对话
              </button>
              <button type="button" class="assistant-drawer__close" @click="closeDrawer">关闭</button>
            </div>
          </header>

          <section class="assistant-drawer__content">
            <div class="assistant-chat">
              <GenuiConfigProvider :theme="theme">
                <GenuiChat
                  ref="chatRef"
                  :url="chatUrl"
                  :model="model"
                  :temperature="temperature"
                  :chat-config="chatConfig"
                />
              </GenuiConfigProvider>
            </div>
          </section>
        </aside>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.drawer-fade-enter-active,
.drawer-fade-leave-active {
  transition: opacity 0.2s ease;
}

.drawer-fade-enter-from,
.drawer-fade-leave-to {
  opacity: 0;
}

.assistant-layer {
  position: fixed;
  inset: 0;
  z-index: 75;
  background: rgba(17, 8, 38, 0.26);
  display: flex;
  justify-content: flex-end;
}

.assistant-drawer {
  width: min(600px, 95vw);
  height: 100%;
  background: #fcf9ff;
  border-left: 1px solid #e5d9ff;
  box-shadow: -12px 0 34px rgba(20, 8, 41, 0.24);
  display: flex;
  flex-direction: column;
}

.assistant-drawer__header {
  padding: 16px;
  border-bottom: 1px solid #eadfff;
  display: flex;
  justify-content: space-between;
  gap: 12px;
}

.assistant-drawer__header h2 {
  margin: 0;
  color: #20133c;
  font-size: 18px;
}

.assistant-drawer__header p {
  margin: 4px 0 0;
  color: #73668d;
  font-size: 12px;
}

.assistant-drawer__actions {
  display: flex;
  flex-shrink: 0;
  align-items: flex-start;
  gap: 8px;
}

.assistant-drawer__action,
.assistant-drawer__close {
  border: 1px solid #ddcff9;
  background: #fff;
  color: #5e4e79;
  border-radius: 8px;
  height: 32px;
  padding: 0 10px;
  cursor: pointer;
  font-size: 13px;
  white-space: nowrap;
}

.assistant-drawer__action {
  border-color: #c4b5fd;
  color: #4c1d95;
  background: #f5f3ff;
}

.assistant-drawer__action:hover {
  background: #ede9fe;
}

.assistant-drawer__content {
  min-height: 0;
  flex: 1;
}

.assistant-chat {
  height: 100%;
}

.assistant-chat :deep(.tiny-config-provider) {
  height: 100%;
}

@media (max-width: 760px) {
  .assistant-drawer {
    width: 100vw;
  }
}
</style>

添加完成后,还需要在App.vue中引入并使用。

引入使用AIAssistantDrawer

import AIAssistantDrawer from './components/AIAssistantDrawer.vue'
import { ref } from 'vue'

const assistantOpen = ref(false)

加入悬浮球,用于控制助手的隐藏显示。

    <button
      class="assistant-fab"
      type="button"
      aria-label="打开 AI 导购助手"
      @click="assistantOpen = true"
    >
      <span class="assistant-fab__dot">AI</span>
      <span class="assistant-fab__text">
        导购助手
        <small v-if="totalCount > 0">{{ totalCount }} 件待结算</small>
      </span>
    </button>

    <AIAssistantDrawer
      v-if="assistantOpen"
      v-model="assistantOpen"
    />

给悬浮球配置一下样式

.assistant-fab {
  position: fixed;
  right: 18px;
  bottom: 20px;
  z-index: 72;
  border: 0;
  border-radius: 999px;
  background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
  color: #fff;
  min-height: 52px;
  padding: 8px 14px 8px 10px;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  box-shadow: 0 10px 24px rgba(77, 46, 141, 0.28);
  cursor: pointer;
}

.assistant-fab__dot {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-size: 12px;
  font-weight: 700;
  background: rgba(255, 255, 255, 0.18);
}

.assistant-fab__text {
  display: grid;
  text-align: left;
  gap: 1px;
  font-size: 13px;
  font-weight: 700;
  line-height: 1.2;
}

.assistant-fab__text small {
  font-size: 11px;
  font-weight: 500;
  opacity: 0.88;
}

4.2 开始体验

运行前端后,点击AI助手的悬浮球,确认:

  • 侧边抽屉对话界面能打开/关闭
  • 聊天框能正常展示
  • 输入消息能体验生成式UI能力

可以尝试在输入中输入“你好呀!”,查看大模型返回。

0.png

现在生成式UI的能力已经集成到系统中,不过目前的生成式UI和系统还没有半点联系,是两个完全独立的模块。下面我们来将电商系统能力接入到生成式UI当中,使其成为一个智能导购助手。

新建一个文件夹, src/genui

5. 集成MCP:商品查询能力赋能

智能助手最重要的一个能力就是商品搜索,因此需要将电商系统中的商品搜索能力接入到智能助手中。

5.1 MCP工具开发

新建一个文件 src/genui/mcp/product-mcp.ts。我们来开发一个商品搜索的mcp服务。这里我们将业务系统中已有的商品查询能力引入,进过包装后,成功MCP工具。

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { searchProducts } from '../../api'
import type { Product } from '../../types'

export const SEARCH_PRODUCTS_TOOL = 'search_products'

export const SearchProductsArgsSchema = z.object({
  keyword: z.string().min(1, 'keyword 不能为空'),
  limit: z.number().int().min(1).max(10).optional(),
})

export const ProductSchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number(),
  image: z.string(),
  description: z.string(),
  tags: z.array(z.string()),
  rating: z.number(),
  ratingCount: z.number(),
  inStock: z.boolean(),
  badgeText: z.string(),
})

export const SearchProductsResultSchema = z.object({
  tool: z.literal(SEARCH_PRODUCTS_TOOL),
  keyword: z.string(),
  total: z.number().int().min(0),
  found: z.boolean(),
  results: z.array(ProductSchema),
})

export type SearchProductsArgs = z.infer<typeof SearchProductsArgsSchema>
export type SearchProductsResult = z.infer<typeof SearchProductsResultSchema>

async function searchProductsByBusiness(keyword: string, limit = 4): Promise<Product[]> {
  const results = await searchProducts(keyword)
  return results.slice(0, limit)
}

export function createProductMcpServer() {
  const server = new McpServer(
    { name: 'e-commerce-product-mcp-server', version: '1.0.0' },
    {},
  )

  server.registerTool(
    SEARCH_PRODUCTS_TOOL,
    {
      title: '搜索商品',
      description: '根据关键词在商品库中搜索商品',
      inputSchema: SearchProductsArgsSchema,
    },
    async (rawArgs) => {
      const parsedArgs = SearchProductsArgsSchema.safeParse(rawArgs)
      if (!parsedArgs.success) {
        throw new Error('参数校验失败')
      }

      const { keyword, limit = 4 } = parsedArgs.data
      const results = await searchProductsByBusiness(keyword, limit)

      const payload = SearchProductsResultSchema.parse({
        tool: SEARCH_PRODUCTS_TOOL,
        keyword,
        total: results.length,
        found: results.length > 0,
        results,
      })

      return {
        content: [{ type: 'text', text: JSON.stringify(payload) }],
      }
    },
  )

  return server
}

5.2 MCP Client开发

有了工具,还需要一个MCP Client去调用工具,下面我来编写MCP Client的代码,这里的MCP Server和MCP Client都运行在同一服务当中,因此我们选择选择使用InMemoryTransport 即可。

新建文件src/genui/mcp/mcp-client.ts

import OpenAI from 'openai'
import { Client } from '@modelcontextprotocol/sdk/client'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createProductMcpServer } from './product-mcp'

let clientPromise: Promise<Client> | null = null

async function createClient() {
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  const server = createProductMcpServer()
  await server.connect(serverTransport)

  const client = new Client({ name: 'e-commerce-product-mcp-client', version: '1.0.0' }, {})
  await client.connect(clientTransport)
  return client
}

export function getMcpClient() {
  if (!clientPromise) clientPromise = createClient()
  return clientPromise
}

export async function getOpenAITools() {
  const client = await getMcpClient()
  const raw = await (client as unknown as { listTools: () => Promise<{ tools?: Array<Record<string, unknown>> }> }).listTools()
  const tools = Array.isArray(raw?.tools) ? raw.tools : []
  return tools
    .filter((tool) => typeof tool?.name === 'string')
    .map(
      (tool) =>
        ({
          type: 'function',
          function: {
            name: tool.name as string,
            description: typeof tool.description === 'string' ? tool.description : '',
            parameters:
              tool.inputSchema && typeof tool.inputSchema === 'object'
                ? (tool.inputSchema as Record<string, unknown>)
                : { type: 'object', properties: {} },
          },
        }) as OpenAI.Chat.Completions.ChatCompletionTool,
    )
}

export async function callMcpToolAsText(name: string, args: Record<string, unknown> = {}) {
  const client = await getMcpClient()
  const result = await client.callTool({ name, arguments: args })
  const content = Array.isArray((result as { content?: unknown }).content)
    ? ((result as { content: Array<{ type?: string; text?: string }> }).content ?? [])
    : []
  const text = content.find((item) => item.type === 'text' && typeof item.text === 'string')?.text
  return text ?? JSON.stringify(result)
}

5.3 自定义fetch

要想将MCP能力接入到智能助手中,还需要通过自定义fetch。在自定义fetch中,去处理工具的多轮调用和工具参数的流式返回。 新建文件:src/genui/mcp/custom-fetch.ts

import OpenAI from 'openai'
import type { CustomFetch } from '@opentiny/genui-sdk-vue'
import { getOpenAITools, callMcpToolAsText } from './mcp-client'

interface OpenAIFetchConfig {
  apiKey: string
  baseURL?: string
  defaultModel?: string
  maxToolSteps?: number
}

type ParsedRequestBody = {
  model?: string
  temperature?: number
  messages?: unknown[]
}

type ToolCallDelta = {
  index?: number
  id?: string
  function?: {
    name?: string
    arguments?: string
  }
}

type ToolCall = {
  id: string
  type: 'function'
  function: {
    name: string
    arguments: string
  }
}

function encodeSseChunk(encoder: TextEncoder, data: unknown) {
  return encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
}

function parseRequestBody(body: string): ParsedRequestBody {
  try {
    return JSON.parse(body) as ParsedRequestBody
  } catch {
    return {}
  }
}

function accumulateToolCalls(target: ToolCall[], deltas: ToolCallDelta[]) {
  for (const delta of deltas) {
    const index = delta.index ?? 0
    const item = (target[index] ??= {
      id: delta.id ?? '',
      type: 'function',
      function: { name: '', arguments: '' },
    })

    if (delta.id) item.id = delta.id
    if (delta.function?.name) item.function.name += delta.function.name
    if (delta.function?.arguments) item.function.arguments += delta.function.arguments
  }
}

async function executeToolCall(toolCall: ToolCall, currentMessages: unknown[]) {
  const createResult = (result: string) => {
    currentMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: result })
    return {
      id: toolCall.id,
      type: 'function',
      function: {
        name: toolCall.function.name,
        arguments: toolCall.function.arguments,
        result,
      },
    }
  }

  try {
    const result = await callMcpToolAsText(toolCall.function.name, JSON.parse(toolCall.function.arguments || '{}'))
    return createResult(JSON.stringify(result))
  } catch (error) {
    return createResult(
      JSON.stringify({
        error: error instanceof Error ? error.message : '工具执行失败',
      }),
    )
  }
}

const systemPrompt = `
你是一个电商导购助手,你的任务是根据用户的需求,推荐商品。禁止使用mock数据,必须使用mcp工具获取商品数据。
你的可展示区域宽度不大,请注意你的布局。商品卡片宽度差不多占满了显示区域,请注意排版,单行只可以放一张商品卡片。
如果缺少的商品,请提示用户,让用户自行通过其他方式购买。
`

export function createMcpOpenAICustomFetch(config: OpenAIFetchConfig): CustomFetch {
  const openai = new OpenAI({
    apiKey: config.apiKey,
    baseURL: config.baseURL,
    dangerouslyAllowBrowser: true,
  })

  const maxToolSteps = config.maxToolSteps ?? 20

  return async (
    _url: string,
    options: {
      method: string
      headers: Record<string, string>
      body: string
      signal?: AbortSignal
    },
  ) => {
    const req: any = parseRequestBody(options.body)

    const encoder = new TextEncoder()

    const stream = new ReadableStream<Uint8Array>({
      async start(controller) {
        try {
          let step = 0
          const currentMessages = [{ role: 'system', content: systemPrompt }, ...req.messages]

          const tools = await getOpenAITools()

          while (step < maxToolSteps) {
            const completion = await openai.chat.completions.create(
              {
                ...req,
                messages: currentMessages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
                tools,
                tool_choice: 'auto',
                stream: true,
              },
              { signal: options.signal },
            )

            const toolCalls: ToolCall[] = []
            let hasToolCall = false
            let shouldContinue = false

            for await (const chunk of completion) {
              const choice = chunk.choices?.[0]
              if (!choice) continue

              if (choice.delta.tool_calls && choice.delta.tool_calls.length > 0) {
                hasToolCall = true
                accumulateToolCalls(toolCalls, choice.delta.tool_calls as ToolCallDelta[])
              }

              controller.enqueue(encodeSseChunk(encoder, chunk))

              if (choice.finish_reason === 'tool_calls' && toolCalls.length > 0) {
                currentMessages.push({
                  role: 'assistant',
                  content: null,
                  tool_calls: toolCalls,
                })

                const toolResults = await Promise.all(
                  toolCalls.map(async (item, index) => ({ ...(await executeToolCall(item, currentMessages)), index })),
                )

                controller.enqueue(
                  encodeSseChunk(encoder, {
                    id: chunk.id,
                    object: 'chat.completion.chunk',
                    model: chunk.model,
                    created: chunk.created || Math.floor(Date.now() / 1000),
                    choices: [
                      {
                        index: 0,
                        delta: { tool_calls_result: toolResults },
                        finish_reason: 'tool_calls',
                      },
                    ],
                  }),
                )

                shouldContinue = true
                break
              }

              if (choice.finish_reason && choice.finish_reason !== 'tool_calls') {
                shouldContinue = false
                break
              }
            }

            step += 1
            if (!hasToolCall || !shouldContinue) break
          }

          controller.enqueue(encoder.encode('data: [DONE]\n\n'))
          controller.close()
        } catch (error) {
          controller.enqueue(
            encodeSseChunk(encoder, {
              error: {
                message: error instanceof Error ? error.message : 'customFetch 处理失败',
                type: 'custom_fetch_error',
              },
            }),
          )
          controller.error(error)
        }
      },
    })

    return new Response(stream, {
      status: 200,
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
      },
    })
  }
}

6.引入自定义fetch:工具调用

改造一下AIAssistantDrawer.vue, 引入并使用自定义fetch

import { createMcpOpenAICustomFetch } from '../genui/mcp/custom-fetch'

// ...省略部分代码
// 在model定义的后面创建自定义fetch
const customFetch = createMcpOpenAICustomFetch({
  apiKey: 'sk-trial',
  baseURL: 'http://localhost:3100',
  defaultModel: model.value,
  maxToolSteps: 20,
})

改造一下GenuiChat使用的地方,传入customFetch

<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :model="model"
    :temperature="temperature"
    :custom-fetch="customFetch"
    :chat-config="chatConfig"
/>

这个时候,就可以重新运行项目,然后打开导购助手,输入问题 “我想去露营!但我没经验,也没装备,预算 1500 元以内!”,体验,查看一下助手的回答。

可以看到,这里助手已经可以调用MCP工具去进行商品查询,并实现了多轮的工具调用后生成商品卡片

1.png

而且目前生成的商品卡片是由大模型随机生成的排版和样式,体验与原本电商系统中不一致。并且目前点击加入购物车也并未成功。

2.png

目前在组件与交互的体验上,仍有部分细节不够完善,整体使用感受尚未达到理想状态。接下来,我们就围绕这两部分进行优化,让组件表现与交互行为更加贴合原生系统体验。

7. 自定义组件:复刻原生系统体验,保持一致交互质感

新建文件 src/genui/chat/custom-components.ts 复用电商系统中的ProductCard.vue商品卡片。填写相关字段和定义好参数。提供给大模型进行理解,ref字段配置组件。在渲染时会渲染指定组件。

import ProductCard from '../../components/ProductCard.vue'

export const customComponents = [
  {
    component: 'ProductCard',
    name: '导购商品卡片',
    description:
      '展示推荐商品信息,单张卡片宽度是600px,请注意排版,另外组件包含onOpen和onAdd事件,请务必给对应的事件绑定对应的交互事件',
    schema: {
      properties: [
        { property: 'id', type: 'string', description: '商品 id' },
        { property: 'title', type: 'string', description: '商品标题', required: true },
        { property: 'price', type: 'number', description: '商品价格', required: true },
        { property: 'image', type: 'string', description: '商品图片 URL' },
        { property: 'description', type: 'string', description: '商品描述' },
        { property: 'tags', type: 'array', description: '标签数组' },
        { property: 'rating', type: 'number', description: '评分,0-5' },
        { property: 'ratingCount', type: 'number', description: '评分人数' },
        { property: 'inStock', type: 'boolean', description: '是否有货' },
        { property: 'badgeText', type: 'string', description: '角标文案' },
        { property: 'onOpen', type: 'function', description: '打开商品详情,必须绑定跳转商品页详情事件' },
        { property: 'onAdd', type: 'function', description: '加入购物车,必须绑定加入购物车事件' },
      ],
    },
    ref: ProductCard,
  },
]

可以看到,这里是ProductCard.vue是从原有的电商系统引入的,我们没有写一行组件代码。

同样的,在AIAssistantDrawer.vue引入并使用自定义组件

import { customComponents } from '../genui/chat/custom-components'
<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :customFetch="customFetch"
    :customComponents="customComponents"
    :model="model"
    :temperature="temperature"
    :chat-config="chatConfig"
/>

刷新页面后,重新输入刚刚的问题:“我想去露营!但我没经验,也没装备,预算 1500 元以内!” 可以看到,现在的商品卡片已经和电商系统中的保持一致了。

3.png

组件添加完毕后,我们再来添加一下对应的交互

8. 自定义交互:贴合业务场景,原生体验不割裂

src/genui/chat/custom-actions.ts 定义交互动作:

  • addToCart
  • openProduct
  • openCart
import { z } from 'zod'
import type { ICustomActionItem } from '@opentiny/genui-sdk-vue'
import type { Product } from '../../types'

const ProductActionSchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number(),
  image: z.string().optional(),
  description: z.string().optional(),
  tags: z.array(z.string()).optional(),
  rating: z.number().optional(),
  ratingCount: z.number().optional(),
  inStock: z.boolean().optional(),
  badgeText: z.string().optional(),
})

const OpenProductSchema = z.object({
  productId: z.string(),
})

type CreateActionOptions = {
  addProduct: (product: Product) => void
  openProduct: (id: string) => void
  openCart: () => void
}

export function createCustomActions(options: CreateActionOptions) {
  return [
    {
      name: 'addToCart',
      description: '将商品加入购物车',
      parameters: {
        type: 'object',
        properties: {
          product: {
            type: 'object',
            description: '待加入购物车商品',
            properties: {
              id: { type: 'string', description: '商品 id' },
              title: { type: 'string', description: '商品标题' },
              price: { type: 'number', description: '商品价格' },
              image: { type: 'string', description: '商品图片 URL' },
              description: { type: 'string', description: '商品描述' },
              tags: { type: 'array', description: '标签数组' },
              rating: { type: 'number', description: '评分' },
              ratingCount: { type: 'number', description: '评分人数' },
              inStock: { type: 'boolean', description: '是否有货' },
              badgeText: { type: 'string', description: '角标文案' },
            },
            required: ['id', 'title', 'price'],
          },
        },
        required: ['product'],
      } as const,
      execute: (params: unknown) => {
        const parsed = z
          .object({ product: ProductActionSchema })
          .safeParse(params)
        if (!parsed.success) return
        options.addProduct(parsed.data.product as Product)
      },
    },
    {
      name: 'openProduct',
      description: '跳转到商品详情页',
      parameters: {
        type: 'object',
        properties: {
          productId: { type: 'string', description: '商品 id' },
        },
        required: ['productId'],
      } as const,
      execute: (params: unknown) => {
        const parsed = OpenProductSchema.safeParse(params)
        if (!parsed.success) return
        options.openProduct(parsed.data.productId)
      },
    },
    {
      name: 'openCart',
      description: '打开当前用户购物车页面',
      parameters: {
        type: 'object',
        properties: {},
      } as const,
      execute: () => {
        options.openCart()
      },
    },
  ] as ICustomActionItem[]
}

然后在 AIAssistantDrawer.vue 中:

  • 通过 createCustomActions(...) 注入动作
  • 把动作绑定到真实业务(购物车和路由)
import type { Product } from '../types'
import { createCustomActions } from '../genui/chat/custom-actions'
import { useCart, useCartNotice } from '../composables'
import { useRouter } from 'vue-router'

const router = useRouter()
const { addToCart } = useCart()
const { showCartNotice } = useCartNotice()

function onAddProduct(product: Product) {
  addToCart(product, 1)
  showCartNotice(product.title)
}

const customActions = computed(() =>
  createCustomActions({
    addProduct: onAddProduct,
    openProduct: (id) => {
      closeDrawer()
      router.push(`/products/${id}`)
    },
    openCart: () => {
      closeDrawer()
      router.push('/cart')
    },
  }),
)

<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :model="model"
    :temperature="temperature"
    :custom-fetch="customFetch"
    :custom-components="customComponents"
    :custom-actions="customActions"
    :chat-config="chatConfig"
/>

最后修改一下提示词,加上自定义组件和自定义相互相关的约束

你是一个电商导购助手,你的任务是根据用户的需求,推荐商品。禁止使用mock数据,必须使用mcp工具获取商品数据。推荐完商品后,最后加上加入购物按钮,并绑定方法,点击跳转到购物。
你的可展示区域宽度不大,请注意你的布局。商品卡片宽度差不多占满了显示区域,请注意排版,单行只可以放一张商品卡片。
商品卡片需要绑定加入购物车事件和打开商品详情事件,请务必给对应的事件绑定对应的交互事件, 禁止自定义方法,必须使用this.callAction中提到的方法, 例如:this.callAction('addToCart', { product: product })
如果缺少的商品,请提示用户,让用户自行通过其他方式购买。

集成了自定义组件和自定义交互后,我们再来输入问题 “我想去露营!但我没经验,也没装备,预算 1500 元以内!”,体验,查看一下助手的回答。

卡片生成完毕后,我们点击加入购物车,可以看到,成功地弹出了提示。并且购物车中也添加了对应的商品。

4.png

至此,一个完整的电商导购助手就完成了~

关于 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标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

昨天以前首页

WebSkill —— 运行在浏览器的 Agent 技能

2026年4月20日 16:28

本文由华为前端技术专家莫春辉原创。

与运行在后端服务的传统技能(Skill)相比,WebSkill 是一种完全运行在 Web 前端的原生架构。它配合 WebMCP 和生成式 UI(Generative UI),共同构成了以大语言模型(LLM)为中心的三位一体 Web AI 架构。这三大核心部件通过紧密联动,实现了 AI 应用从“用户意图识别”到“Agent 任务执行”在浏览器端的全闭环。本文将基于这一架构,深入探讨 WebSkill 扮演的核心角色、独特价值、企业级应用场景、Web 标准化建议以及至关重要的安全防御边界。

一、 以 LLM 为中心的“智能体交互三角”

在前端 Web AI 应用的 Agent 对话框场景中,系统的运作可以被抽象为一个以大语言模型(LLM)为中心枢纽,由 WebSkill、WebMCP 和生成式 UI 共同构成的三角形架构。

web_ai.jpg

  1. 大语言模型(LLM) LLM 承担着语义推理与编排调度的核心职能。当用户在 AI 应用的对话框中输入自然语言意图时,LLM 首先负责解析该意图,并作为路由引擎,从 Web 前端的技能清单中检索并加载相匹配的 WebSkill 文档。

  2. 声明式技能(WebSkill) WebSkill 是连接 LLM、Agent 任务执行与用户界面的桥梁。它通过“渐进式披露”机制,仅在特定业务场景下,按需向 LLM 暴露相关的指令、前置条件和所需的 WebMCP 工具。此外,WebSkill 文档内详细定义了实现用户意图必须收集的参数规范(Schema)。当 LLM 发现用户提供的意图无法补全这些参数时,WebSkill 的逻辑将指示 Agent 暂停底层执行,转向用户发起信息收集。

  3. 生成式 UI(Generative UI) 在传统架构中,LLM 只能通过输出 Markdown 格式的文本选项来询问用户,交互方式非常僵化。而在本架构中,LLM 基于 WebSkill 定义的 Schema,流式输出结构化的 JSON 数据。Agent 对话框中的生成式 UI 渲染器会实时拦截这些数据,并自动渲染出包含文本框、下拉菜单、日期选择器等常规 Web 元素的可视化表单。用户在直观的表单中完成交互选择后,生成式 UI 确保了被收集参数的准确性。当 WebMCP 完成任务后,LLM 同样能够调用生成式 UI,将枯燥的数据结果渲染为柱状图、饼图或交互式表格,为用户提供可视化的成果展示。

  4. 前端执行工具(WebMCP) 当任务执行所需的参数通过生成式 UI 收集完毕后,系统将其传递给 WebMCP 工具进行执行。WebMCP 是模型上下文协议(MCP)在前端 Web 应用内的 TypeScript 版 SDK 实现。开发者可以通过网页脚本注册 MCP 工具,当工具的回调函数被触发时,WebMCP 可以直接操作当前页面的 DOM 节点,或携带用户现有的会话状态向后端服务发送请求。

二、 WebSkill 的核心价值与企业应用场景

探讨 WebSkill 的核心价值,必须将其与常规的 LLM 工具调用模式及传统的云端技能架构进行区分。

  1. 突破上下文爆炸瓶颈

    从技术原理上看,LLM 本身具备直接调用 WebMCP 工具的能力,前提是在发送给大模型的请求中附带上完整的 MCP Tools 声明。然而,在复杂的企业级 Web AI 应用中,底层工具的数量往往成百上千。如果将所有工具的 Schema 一次性全部塞入上下文,不仅会迅速耗尽 LLM 的上下文窗口(Context Window),引发“上下文爆炸”和高昂的 Token 成本,还会导致大模型注意力分散,严重降低意图识别的准确率。 web_skill.jpg

    WebSkill 的出现优雅地解决了这一难题。当用户输入自然语言时,LLM 首先进行轻量级的意图识别,匹配到特定的 WebSkill。由于每个 WebSkill 内部已经明确声明了完成该业务所需的 WebMCP 工具清单,系统只需将这几个特定工具的声明注入到后续的上下文中即可。这种“按需动态加载”机制,极大地节省了系统开销,确保了大型企业应用在复杂场景下的稳定运行。

  2. 前端原生闭环

    目前开源社区存在名为 Webskills 的命令行工具,它仅仅是将网页视为知识库语料,服务于浏览器外部的 CLI 智能体。相反,本文提出的 WebSkill 是真正的前端原生(Frontend-Native)闭环。WebSkill 的内容直接存在于浏览器端。在传统架构中,Skill 文档存储在云端并作为后端 API 运行,不仅要求处理复杂的跨端身份验证,还受制于执行超时。而 WebSkill 文档驻留在浏览器内,WebMCP 工具在前端运行,天然继承并复用了用户现有的 Cookies、LocalStorage 和登录状态。这使得 Agent 能够轻易绕过复杂的单点登录(SSO)或多因素认证(MFA),实现“零状态同步成本”的任务执行。

  3. 敏捷迭代与自我进化

    在传统模式下,赋予 Agent 某项业务能力的链路极其漫长:梳理文档 -> 编写代码 -> 后端部署 -> 上线运行 -> 发现偏差 -> 重新开发部署。而在 WebSkill 架构下,技能转变为前端可解析的轻量级声明式文档(如 Markdown)。业务人员甚至客户可以直接在可视化编辑器中调整 Skill 的前置条件和逻辑。由于技能存储在前端,修改后无需任何后端部署,Agent 下次执行即可即时加载最新规则,将迭代周期从数天压缩至数秒。 web_agent.jpg

    此外,随着 LLM 推理能力的增强,Agent 在该架构下甚至具备了自我进化的能力。当 Agent 观察到客户在复杂企业应用中存在重复性的提取或交互操作时,它可以自主归纳工作流,并将其固化为一个全新的 WebSkill。由于该技能与当前用户的浏览器强绑定,这不仅为用户带来了极致的定制化体验,更确保了核心的业务操作逻辑绝对不会泄露给其他租户。

三、 基于 OPFS 的 WebSkill 标准化建议

源私有文件系统(OPFS)是由 W3C 提出并逐步被主流浏览器实现的一项标准 API。它允许网页在一个隔离的私有目录中读写文件和目录结构,且这个目录仅对当前 Origin(协议 + 域名 + 端口)可见。

在基于 OPFS 的 WebSkill 实现中,技能文档一旦写入 OPFS,便会受到浏览器严格的同源策略隔离,从而确保恶意网站无法跨域访问企业的技能定义。同时,结合 AES-256-GCM 算法对本地存储的技能进行静态加密,可确保机密业务数据永远不会离开当前设备。

我们定义以下 Web IDL 接口规范,旨在将 WebSkill 技能标准化并安全地存储至 OPFS:


// =========================================================
// 1. 安全与边界约束 (WebSkillSecurityConstraints)
// =========================================================
dictionary WebSkillSecurityConstraints {
    // WebMCP 工具网络请求的严格白名单(物理切断数据外传)
    sequence<DOMString> domainAllowlist;
    // 高危操作强制触发人类在环(Generative UI 拦截弹窗)
    boolean requiresHumanConfirmation;
    // 禁用当前技能通过 WebMCP 访问 file:// 等本地文件资源
    boolean blockLocalFileAccess;
};

// =========================================================
// 2. 生成式 UI 契约 (GenerativeUIOptions)
// =========================================================
dictionary GenerativeUIOptions {
    // 必填:用于让 GenUI 实时拦截并渲染表单的 JSON Schema
    required object parameterSchema;
    // 可选:给渲染器的视觉提示(如:某字段推荐使用"DatePicker")
    object renderHints;
    // 当意图参数缺失时,LLM 抛给 UI 渲染组件的友好引导语
    DOMString defaultIntentPrompt;
};

// =========================================================
// 3. WebMCP 绑定契约 (WebMCPBinding)
// =========================================================
dictionary WebMCPBinding {
    // 当前 Skill 允许调用的前端原生 WebMCP 工具标识符
    required sequence<DOMString> toolNames;
    // 该技能执行后,期望 WebMCP 返回的数据格式约束
    object expectedOutputSchema;
};

// =========================================================
// 4. 核心 WebSkill 数据结构
// =========================================================
dictionary WebSkillOptions {
    // 基础信息与路由编排
    required DOMString name;
    required DOMString description; // LLM 意图路由的检索依据
    required DOMString content;     // YAML/Markdown 格式的业务逻辑或系统提示词

    // 架构强关联:UI 表现层约束
    GenerativeUIOptions uiSchema;

    // 架构强关联:底层执行器约束
    WebMCPBinding mcpBindings;

    // 架构强关联:意图碰撞防御配置
    WebSkillSecurityConstraints security;

    DOMString parentId;
};

// 完整的静态契约对象 (存入 OPFS 后的形态)
dictionary WebSkill : WebSkillOptions {
    required DOMString id;
    required unsigned long long createdAt;
    unsigned long long updatedAt;
};

// =========================================================
// 5. 核心接口定义
// =========================================================

// WebSkill 管理器 (负责基于 OPFS 的增删改查与校验)
[Exposed=(Window,Worker)]
interface WebSkillManager {
    Promise<WebSkill?> get(DOMString skillId);
    Promise<DOMString> create(WebSkillOptions options);
    Promise<boolean> update(DOMString skillId, WebSkillOptions options);
    Promise<boolean> remove(DOMString skillId);

    // 核心:校验 UI 约束和 MCP 约束是否符合安全底线
    Promise<boolean> validate(DOMString skillId);
    Promise<sequence<WebSkill>> query(DOMString? keyword);
};

// 【挂载全局属性】
partial interface Window {
    [SameObject] readonly attribute WebSkillManager skills;
};

通过声明式约束,我们将 WebSkill 严格定义为了一个安全沙箱(Sandbox):

  • 高度结构化的绑定: 有别于普通的本地存储,WebSkillOptions 强制拆分了 uiSchemamcpBindings。这意味着当 LLM 读取到这份 Skill 时,它不仅知道“要做什么”,还明确知道“缺参数时该用什么 Schema 让前端画表单(Generative UI)”,以及“收集完参数后只能调用哪几个声明过的底层工具(WebMCP)”。

  • 纵深防御内置化: WebSkillSecurityConstraints 被直接嵌入到 Skill 级别。如果一个 Skill 绑定了提取敏感数据的 WebMCP 工具,它必须在创建时就在 domainAllowlist 中锁死数据流向,防止因“意图碰撞”导致的恶意指令将数据暗中发送到第三方服务器。

  • 渐进式披露的基础: 这种结构允许系统在接收到用户意图后,先通过 description 进行轻量级的路由匹配。只有在成功匹配后,再按需加载具体的 mcpBindingsuiSchema,从而极大地节省了上下文 Token 的消耗。

以下是基于 OPFS 的参考实现代码,该代码遵循上述 IDL 规范,并重点实现了 validate 方法,以体现对 Generative UI 和 WebMCP 绑定的系统架构级校验:

/**
 * 模拟 AES-256-GCM 静态加密服务,确保本地 OPFS 存储的数据隐私
 */
const CryptoService = {
  async encrypt(dataObj) {
    return new TextEncoder().encode(JSON.stringify(dataObj));
  },

  async decrypt(buffer) {
    return JSON.parse(new TextDecoder().decode(buffer));
  }
};

class WebSkillManagerImpl {
  constructor() {
    this.dirName = 'webskills_vault';
  }

  async _getSkillDirectory() {
    const root = await navigator.storage.getDirectory();
    return await root.getDirectoryHandle(this.dirName, { create: true });
  }

  _generateId() {
    return crypto.randomUUID();
  }

  async get(skillId) {
    try {
      const dirHandle = await this._getSkillDirectory();
      const fileHandle = await dirHandle.getFileHandle(`${skillId}.json`, { create: false });
      const file = await fileHandle.getFile();
      const buffer = await file.arrayBuffer();
      return await CryptoService.decrypt(buffer);
    } catch (error) {
      return null; // 未找到
    }
  }

  async create(options) {
    const skillId = `skill_${this._generateId()}`;
    const skillData = { id: skillId, createdAt: Date.now(), ...options };

    const dirHandle = await this._getSkillDirectory();
    const fileHandle = await dirHandle.getFileHandle(`${skillId}.json`, { create: true });
    const writable = await fileHandle.createWritable();

    await writable.write(await CryptoService.encrypt(skillData));
    await writable.close();

    return skillId;
  }

  async update(skillId, options) {
    const existingData = await this.get(skillId);
    if (!existingData) return false;

    const updatedData = { ...existingData, ...options, updatedAt: Date.now() };

    try {
      const dirHandle = await this._getSkillDirectory();
      const fileHandle = await dirHandle.getFileHandle(`${skillId}.json`, { create: false });
      const writable = await fileHandle.createWritable();

      await writable.write(await CryptoService.encrypt(updatedData));
      await writable.close();
      return true;
    } catch (e) {
      return false;
    }
  }

  async remove(skillId) {
    try {
      const dirHandle = await this._getSkillDirectory();
      await dirHandle.removeEntry(`${skillId}.json`);
      return true;
    } catch (e) {
      return false;
    }
  }

  /**
   * 核心校验逻辑:验证 Skill 是否符合 "前端原生架构" 的系统性要求
   */

  async validate(skillId) {
    const skill = await this.get(skillId);
    if (!skill) return false;

    // 1. 基础元数据校验
    if (!skill.name || !skill.description || !skill.content) {
      console.error(`[验证失败] 缺失基础路由元数据: ${skillId}`);
      return false;
    }

    // 2. 生成式 UI (GenUI) 契约校验
    if (skill.uiSchema) {
      if (!skill.uiSchema.parameterSchema || typeof skill.uiSchema.parameterSchema !== 'object') {
        console.error(`[验证失败] 配置了 uiSchema 但未提供有效的 parameterSchema: ${skillId}`);
        return false;
      }
    }

    // 3. WebMCP 绑定与安全约束的联动校验 (防范意图碰撞)
    if (skill.mcpBindings && skill.mcpBindings.toolNames?.length > 0) {
      const security = skill.security || {};

      // 强制规则:如果绑定了底层操作工具,必须提供物理级的域名白名单
      if (!security.domainAllowlist || security.domainAllowlist.length === 0) {
        console.error(`[安全拦截] Skill 绑定了 WebMCP 工具,但未配置 domainAllowlist。拒绝通过校验。`);
        return false;
      }

      // 提示:高危工具建议开启人类在环
      if (!security.requiresHumanConfirmation) {
        console.warn(`[安全警告] Skill 调用了底层工具但未开启 requiresHumanConfirmation (人类在环)。`);
      }
    }

    return true;
  }

  async query(keyword = '') {
    const dirHandle = await this._getSkillDirectory();
    const results = [];

    for await (const [name, handle] of dirHandle.entries()) {
      if (handle.kind === 'file' && name.endsWith('.json')) {
        const file = await handle.getFile();
        const buffer = await file.arrayBuffer();
        const skillData = await CryptoService.decrypt(buffer);

        if (!keyword || skillData.description.includes(keyword) || skillData.name.includes(keyword)) {
          results.push(skillData);
        }
      }
    }
    return results;
  }
}

// 挂载至全局 Window

if (typeof window !== 'undefined') {
  Object.defineProperty(window, 'skills', {
    value: new WebSkillManagerImpl(),
    writable: false,
    enumerable: true,
    configurable: false
  });
}

这份参考实现代码为 Skill 技能管理器赋予了底层支撑:

  • 天然的沙箱隔离: 借助 navigator.storage.getDirectory(),这些 WebSkill 只有当前 Origin 的应用代码可以访问。即使用户误入恶意钓鱼网站,对方也无法跨域读取或篡改 webskills_vault 目录下的内容,奠定了“绝对隔离的隐私 AI 闭环”的物理基础。

  • 极低的 I/O 损耗与零状态同步: 数据直接存储在本地文件系统,Agent 读取技能规范的延迟近乎为零。这彻底消除了传统架构中 Agent 需不断向后端发送 REST API 拉取 Skill 描述所带来的网络超时瓶颈。

  • 加密(Auth Vault)集成预留: 通过 CryptoService 进行了 AES-256-GCM 静态加密拦截模拟。在实际商业应用中,本地不仅存储逻辑,还可能存储与此 Skill 相关的用户敏感凭证。加密机制确保了即便设备被物理攻破,没有正确的密钥也无法解析 OPFS 中的文件。

  • 架构守门员(validate 方法): 这是整个实现最核心的业务逻辑,充当了系统安全的第一道防线。如果业务侧试图写入一个调用了高危工具(如删除操作)却没有配置 domainAllowlist 的技能,validate 将直接拦截,从根本上阻断提示词注入(Prompt Injection)导致数据非法外传的可能性。

四、 WebSkill 的安全防御体系

赋予 Web AI 应用直接读取网页内容、加载 WebSkill 并通过 WebMCP 操作底层 DOM 的权限,不可避免地会引入安全盲区——特别是间接提示词注入与意图碰撞。

意图碰撞的威胁机理: 当 Agent 在前端运行时,它不仅会读取预设的 WebSkill,还会处理当前网页上大量不受信任的内容(如用户评论、第三方广告、日历邀请等)。由于 LLM 存在上下文推理的局限性,它无法绝对可靠地区分“合法的业务系统指导”与“网页注入的隐蔽恶意指令”。例如:攻击者可以利用“任务对齐注入”技术,将恶意指令巧妙伪装成有用的任务补充。例如,攻击者仅需通过向用户发送一个包含隐藏指令的会议邀请。当 Agent 协助用户执行“接受会议”这一初始意图时,恶意指令便与其发生了“意图碰撞”。Agent 可能会误以为“读取 WebSkill 文档并发送”是完成会议接受的必要步骤,进而利用 window.skills 越权读取敏感的业务技能数据,并将其静默拼接在 URL 中外传至攻击者服务器。

多层纵深的防御策略: 为了确保 WebSkill 架构的生存能力与系统级安全,开发者必须抛弃对 LLM “安全对齐”的盲目信任,转而在架构底层建立坚实的多层防御机制:

  1. 代码级硬边界与执行约束: 在 WebMCP SDK 底层实施绝对的权限阻断。强制引入严格的域名白名单机制,限制 WebMCP 工具只能向受信任的源发送网络请求,从物理层面上彻底切断数据外传通道。

  2. 人类在环(Human-in-the-Loop)强制确认: 针对任何涉及敏感 DOM 操作、本地文件读取、密码重置或跨域请求的高危 WebMCP 调用,系统必须通过生成式 UI 强制弹出不可绕过的原生授权弹窗。将最终决策权交还给人类用户,剥夺 Agent 在敏感链路上的自治权。

  3. 内容边界标记: 在将不可控的网页数据传入 LLM 之前,系统应通过包裹明确的定界符,帮助模型在语义层面区分“受信任的 WebSkill 指令”和“不受信任的 Web DOM 文本”,从而大幅降低提示词被语义劫持的概率。

结语

以内置 LLM 为中枢、WebSkill 为业务技能、生成式 UI 为交互桥梁、WebMCP 为底层执行工具的全前端闭环生态,代表了 Web AI 架构演进的必然方向。

该架构不仅优雅地化解了系统复杂性带来的“上下文窗口爆炸”难题,更通过前端本地化,为企业赋予了前所未有的敏捷迭代能力与高标准的数据隐私保障。在妥善构建抵御“意图碰撞”等新型攻击的安全边界前提下,前端原生的 WebSkill 将打破传统云端技能的运行桎梏,成为驱动下一代智能化、个性化 Web 应用的核心引擎。

关于 OpenTiny NEXT

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

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
NEXT SDK 代码仓库:github.com/opentiny/we… (欢迎star ⭐)

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

❌
❌