阅读视图

发现新文章,点击刷新页面。

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

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

简介:本文以智能导购助手场景为例,带大家实操体验生成式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 技能

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

与运行在后端服务的传统技能(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标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

瑞幸 UI 上 pub.dev 了 —— 22 个 Flutter 组件,与微信小程序版双端对齐

瑞幸 UI 上 pub.dev 了 —— 22 个 Flutter 组件,与微信小程序版双端对齐

把 DESIGN.md 当作跨端的"单一真相",一套设计语言同时喂给 WeChat 小程序和 Flutter。

效果截图

瑞幸-fl-首页.png

瑞幸-fl-方案选择.png

瑞幸-fl-等级卡.png

瑞幸-fl-产品.png

瑞幸-fl-左侧导航.png

瑞幸-fl-通知.png

瑞幸-fl-网格.png

瑞幸-fl-头像.png

瑞幸-fl-按钮.png

背景

之前我写了《我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库》,发布了 npm 包 lkcn-ui

验证 DESIGN.md 真的是"可复用的设计规范"吗,那它至少应该能驱动两个不同的运行时。于是有了这一版:

  • GitHubhttps://github.com/qwfy5287/lkcn-ui-flutter
  • pub.devhttps://pub.dev/packages/lkcn_ui
  • 姊妹项目https://github.com/qwfy5287/lkcn-ui(小程序版)

双端对照

两个仓库,一份 DESIGN.md,相同的 22 个组件:

平台 包名 分发 仓库
微信小程序 lkcn-ui npm qwfy5287/lkcn-ui
Flutter lkcn_ui pub.dev qwfy5287/lkcn-ui-flutter

命名这里踩了个小坑:pub.dev 要求 snake_case,不能用连字符,所以 npm 的 lkcn-ui 到 pub.dev 就成了 lkcn_ui。这是 Dart/Flutter 生态的惯例,不算破坏品牌一致性。

版本号策略是 MAJOR.MINOR 对齐 + PATCH 独立——看到 npm 1.2.3 + pub 1.2.1 就知道 API 对齐、只是 Flutter 单独修了两个 bug。

设计语言的「跨端翻译」

如果说小程序版是把 DESIGN.md 翻译成 WXSS + WXML,那 Flutter 版就是翻译成 Dart Widget。这过程有 5 件事需要做决定:

1. Design Token:CSS 变量 → Dart const class

小程序版把 token 写成 CSS 变量,注入到 page {}

page {
  --lkcn-blue: #1A6EFF;
  --lkcn-radius-md: 24rpx;
}

Flutter 没有 CSS 变量这种运行时机制,但它的类型系统更强。我用 const class 做等价物:

class LkcnColors {
  static const Color primary = Color(0xFF002FA7);      // 克莱因蓝
  static const Color accentOrange = Color(0xFFFF6A3D);
  static const Color accentGold = Color(0xFFC9A66B);
}

class LkcnRadius {
  static const double md = 12;
  static const double pill = 999;
}

使用:

Container(
  decoration: BoxDecoration(
    color: LkcnColors.primary,
    borderRadius: BorderRadius.circular(LkcnRadius.md),
  ),
)

好处是编译期常量、IDE 自动补全、类型安全;坏处是换肤没办法像 CSS 变量那样"覆盖即生效"——要彻底换肤得上 ThemeExtension。首版先不折腾这个。

2. 单位:rpx → logical pixels

小程序的 rpx 基于 750 设计稿,Flutter 的 logical pixel 是独立密度单位。换算规则就一条:rpx = lpt × 2

字号 28rpx 对应 14 lpt,间距 24rpx 对应 12 lpt,圆角 16rpx 对应 8 lpt。习惯了之后是肌肉记忆,但第一次做映射表时你会翻 variables.wxss 翻到吐。

3. 组件 API:kebab-case → PascalCase / enum

  • 组件类:lkcn-buttonLkcnButton
  • 枚举属性:type="primary"LkcnButtonType.primary
  • 事件回调:bind:tap="onClick"onTap: () {}

Flutter 的 enum 比字符串属性严格得多——如果你传了个不存在的 type 字符串,小程序只会默默 fallback,Flutter 直接编译不过。对库作者是好事。

4. 插槽:<slot> → Widget 参数

小程序靠 <slot> 传子内容,支持具名插槽。Flutter 对应的是具名参数:

LkcnCard(
  title: '我的资产',
  child: Column(children: [...]),   // 主内容
  footer: Row(...),                  // footer 槽
)

一个命名参数 = 一个插槽,清晰、类型安全、IDE 能提示。

5. Demo 的组织:pages/demo-*example/lib/demos/*

小程序版每个 demo 是独立 page(wxml/wxss/js/json 四件套),通过 pages.json 注册。Flutter 版按 pub.dev 惯例,example/ 是个独立的可运行 app,每个组件对应一个 .dart 文件,用 MaterialPageRoute 跳转:

example/
├── lib/
│   ├── main.dart              # 按 原子/交互/容器/业务 分组的索引页
│   └── demos/
│       ├── button_demo.dart
│       ├── product_card_demo.dart
│       └── ... (21 个)
└── pubspec.yaml               # path: ../ 引用主包

cd example && flutter run 就能跑,iOS / Android / macOS / Web 四端都能看。这比小程序的"打开微信开发者工具"门槛低多了。

几个还原得比较得意的组件

LkcnStepper:加购从 + 展开到 [-] n [+]

瑞幸菜单页最有辨识度的微交互,Flutter 版用 setState 切两个形态:

LkcnStepper(
  value: _quantity,
  onChanged: (v) => setState(() => _quantity = v),
)

弹性动画走 LkcnMotion.bounce(即 Cubic(0.34, 1.56, 0.64, 1)),跟 WXSS cubic-bezier 常量完全一致。

LkcnPrice:三段式价格渲染

"符号小 + 整数大 + 小数小"的层次是瑞幸价格的灵魂:

LkcnPrice(value: 9.9, original: 32, prefix: '预估到手')

内部把 9.9 拆成 9.9 两段不同字号,¥ 给第三种字号,原价走 TextDecoration.lineThrough

LkcnCouponScroll:票据左侧半圆缺口

小程序版靠 CSS clip-path 裁出缺口,Flutter 没有这个 API。我用 CustomPainter 手画 path:

final path = Path()
  ..moveTo(r, 0)
  ..lineTo(size.width - r, 0)
  // ...
  ..lineTo(0, size.height * 0.5 + 6)
  ..arcToPoint(                          // ← 半圆缺口
    Offset(0, size.height * 0.5 - 6),
    radius: const Radius.circular(6),
    clockwise: false,
  )
  ..close();

最终效果和小程序版几乎一致。CustomPainter 写起来比 CSS clip-path 啰嗦,但控制粒度更细。

LkcnMembershipPlan:会员订阅全流程

方案选择器 + 订阅 CTA + 协议勾选,三件事一个 Widget 解决:

LkcnMembershipPlan(
  plans: const [
    LkcnPlan(name: '连续包月', price: 9.9, badge: '爆款天天 9.9 起'),
    LkcnPlan(name: '月卡', price: 19.9),
  ],
  agreement: '开通会员代表接受',
  agreementLinks: const [
    LkcnAgreementLink(text: '《会员服务协议》'),
    LkcnAgreementLink(text: '《自动续费协议》'),
  ],
  onSubscribe: (plan, agreed) {
    // agreed = false 时可以弹 toast 提示勾选
  },
)

快速上手

pubspec.yaml

dependencies:
  lkcn_ui: ^0.1.0

业务代码:

import 'package:flutter/material.dart';
import 'package:lkcn_ui/lkcn_ui.dart';

class MenuPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: LkcnColors.pageBg,
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          LkcnProductCard(
            image: 'https://.../coconut-latte.png',
            title: '生椰拿铁',
            tags: const ['全球销量第一', 'IIAC 金奖'],
            price: 9.9,
            originalPrice: 32,
            pricePrefix: '预估到手',
            onAdd: () {},
          ),
          const SizedBox(height: 16),
          LkcnButton.cta(
            text: '立即开通连续包月 ¥9.9',
            size: LkcnButtonSize.large,
            block: true,
            round: true,
            onTap: () {},
          ),
        ],
      ),
    );
  }
}

22 个组件速览

  • 原子:Button · Tag · Price · Badge · Avatar
  • 交互:SearchBar · Segment · Stepper · Tabs · Tabbar
  • 容器:Card · Grid · Swiper · NoticeBar · LocationBar · FloatingButton · CategorySidebar
  • 业务:ProductCard · CouponScroll · PromoCard · LevelCard · MembershipPlan

每个的 API 尽量跟 npm 版同名、同语义。小程序那边的 bind:add 事件在 Flutter 是 onAdd,小程序的 custom-class 在 Flutter 通过 child/padding 参数调——这些映射关系看完一遍 README 就能对上号。

一些数据

  • 22 个组件,零第三方依赖(只依赖 Flutter SDK)
  • 约 3000 行 Dart 代码(不含 example)
  • flutter analyze / example && flutter analyze0 警告 0 错误
  • lib/ 目录 25 个 .dart 文件
  • Dart SDK:^3.11.3,Flutter:>=3.22.0
  • MIT License

跨端维护的几条经验

做完这版 Flutter 之后,最深的感受是:跨端组件库的真正难点不在代码,在保持纪律。

  1. DESIGN.md 做单一真相:色值 / 间距 / 圆角这些决策写在文档里,而不是写在某一端代码的注释里。PR 有分歧时,以文档为准。
  2. MAJOR.MINOR 对齐 + PATCH 独立:两端版本号不强求完全一致,但 API 变更要同步发版。
  3. Issue 加端标签[wx] / [flutter] / [design] 三类,避免跨端 issue 混战。
  4. demo 先行:改组件前先改 demo,再改源码 —— 这样能强制你想清楚 API 长什么样。

后续计划

  • 每个组件写 widget test,提 pub.dev Like / Popularity 评分
  • ThemeExtension 版的 Design Token,支持运行时换肤
  • 深色模式
  • GitHub Actions CI:analyze + test + 自动 pub publish
  • VitePress 双端文档站(两端 API 并排展示)

觉得有用的话,欢迎 Star / 试用:

  • GitHub:https://github.com/qwfy5287/lkcn-ui-flutter
  • pub.dev:https://pub.dev/packages/lkcn_ui
  • 小程序版姊妹项目:https://github.com/qwfy5287/lkcn-ui

🧑‍💻 顺便求职

目前正在找工作,前端优先,全栈也可以胜任,坐标 厦门

案例集(前端 / 全栈):my.feishu.cn/wiki/XUmGw8…

有合适岗位欢迎评论或私信,感谢。

谷歌将安卓转向闭源:没有苹果命,得了苹果病

对于经历过 2007-2017 年移动操作系统大洗牌时代的用户,Android 曾是一面旗帜。

在 BBOS、塞班、Windows Mobile「百家争鸣」之后,由谷歌牵头的 Android 成为仅存的硕果。这个开源操作系统,曾经是包容、多样与打破陈规的象征,与隔壁精致、有板有眼,却封闭的 iOS 形成了鲜明对比。

▲ 图|WIRED

然而多年以后,谷歌变了,Android 也变了。

2026 年 4 月 1 日,本来是个大家都开心的日子,谷歌突然宣布:

Android 开发者验证功能自近日起全面推出,所有开发者必须向谷歌注册自己的应用。未来如果系统检测到安装包没有注册信息,将会强制启动 7 天的「安装冷静期」,或者要求用户必须接上电脑用 ADB 授权安装。

这套名为「高级侧载」(advanced sideload)的功能预计在今年 8 月向所有安卓用户推出,「冷静期」功能则计划 2027 年在全球范围施行。

本质上,谷歌封锁了「侧载」应用,即直接通过 .apk 文件直装应用的路子。

▲ 图|Aurich Lawson

从不断收紧的第三方应用侧载限制,到针对全球开发者的强制认证要求,再到令人难绷的「七天安装冷静期」,叠加前面转向闭源的新闻——无不在释放一个令人心寒的信号:

谷歌正在亲手杀掉曾经由它开启的开放 OS 时代。

毕竟如果我们回看 Android 诞生初期,它之所以能够击溃塞班、抗衡 iOS,靠的绝不是单纯「免费」,同时还有那份近乎野蛮的开放性。

▲ 图|TechRadar

正是对于「开放性」的包容,让 Android 成功突围,得以在全球移动 OS 市场上和苹果抗衡,以后来居上的姿势告诉全世界:

手机操作系统,何必是被严密限制的黑盒?它完全可以是一张白纸,由厂商、开发者、用户共同书写——

如果你不喜欢厂商预装的桌面,就直接换一个启动器;Play Store 也不是唯一的下载渠道,各种第三方应用市场百花齐放。

当然,如果你想要的应用没有上架应用商城,完全可以通过侧载的方式安装。

▲ 知名第三方应用市场 F-Droid|Android Authority

这种高自由度和可定制化,就是 Android 森林能够如此茂盛的根基,也是它相比坚持「封闭花园」策略的 iOS 的最大竞争优势。

但今天的谷歌,似乎早已忘记了当年「不作恶」(Don’t be evil)的口号,同时患上了某种「权力焦虑症」——它要控制系统里每一条 ADB 指令的流向,质询每个安装包的来源。

这完全就是「屠龙勇士终成恶龙」的现实版本。

谷歌正在用一种温水煮青蛙的方式,将 Android 变成一个披着「开源」的皮、实则高度集权的私家领地。

▲ 图|How-to-geek

在谈到这些限制时,谷歌永远都只会抛出「安全」和「用户体验」的理由。

诚然,移动安全至关重要,在如今这个手机承载了我们 95% 日常生活的时代来说更是如此,但我们已经经历了太多太多血淋淋的教训——

所有拿「安全」做挡箭牌的商业决策,最后都变成了攫取用户信息获利的手段。

更何况,谷歌在不管不顾地推进这种封闭化进程时,反而让自己露出了一个极其尴尬的姿势。

「没有苹果命,得了苹果病。」这句话以前是形容盲目抄袭苹果产品形态、却忽视了苹果设计逻辑的手机厂商用的,现在用来形容谷歌倒是再贴切不过——

没有人能够否认,iPhone + iOS 软硬件结合的「围墙花园」(walled garden)模式在商业上的成功,iPhone 用户也乐于留在其中。

▲ 图|Apple

但我们必须清楚:苹果能够跑通这一套逻辑的前提,是它真的做到了「为用户提供一个完整的闭环生态」。

iPhone 只能从 App Store 获取应用不假,但 App Store 里的应用不仅会经过苹果严苛的技术审核,更重要的是:苹果的服务足够便捷、稳定,且能够在全球范围提供相对统一的高质量内容。

▲ 图|Apple

反观谷歌 Play Store,光是 Play Store 本身的 Bug 频出和应用质量的良莠不齐,就足以让谷歌梦里的「围墙花园」地基垮掉。

虽然 App Store 里面也有不少粗制滥造的东西,但相比 Play Store 的生态还是小巫见大巫了。

▲ Play Store 付费榜

哪怕谷歌已经扯着嗓子喊了几年的 Play Protect 机制和强制 API 规定,我们依然能在 Play Store 的推荐页乃至首页,看到大量粗制滥造的马甲包。

甚至在 2026 年的今天,Play Store 免费区里面依然潜伏着太多字面意义上的「毒瘤」应用。

▲ 麦卡菲安全通报的数个 Play Store 毒瘤应用|McAfee

而在这种官方渠道无法做到尽善尽美的情况下,谷歌却还要斩断用户寻找第三方替代方案的后路,好一个又当又立的典范。

别忘了,Play Store 同样是锁区的,并且锁区机制相比 App Store 简直有过之而无不及。

对于很多地区的用户来说,侧载 APK 其实跟所谓的极客精神八杆子打不着——而是为了在官方商店堵死的情况下,维持手机的基本功能。

▲ 图|Reddit

而谷歌紧赶慢赶想要关上侧载这扇门,本质上就是在强迫用户接受一个德不配位的商店服务。

谷歌对 Android 核心资产的态度转变,更是让人加深对这个操作系统的不信任。

从去年开始,谷歌就开始宣布 Android 的部分核心功能不再向 AOSP 开放,而是整合进其私有的 GMS 服务中。

换言之,以前「原生」和「类原生」的区别,已经被谷歌自己切割成「Pixel OS」和「其它」。

同时,AOSP 源代码的公开频率和深度也在大幅削减,直至谷歌最后宣布:仅会向部分生产 Android 手机的企业伙伴提供 AOSP 源码。小型硬件品牌、第三方 ROM 和个人开发者,哪凉快哪歇着去吧。

▲ 图|Android Authority

这就是 Android 从开源转向闭源的标志。

那个曾经属于全球开发者的 Android、那个曾经用「农村包围城市」战术赶超 iOS 的 Android,其开放与自由的属性,正在被谷歌从内部一点点掏空。

自从 Pixel 手机的业务站稳脚跟,谷歌就开始试图通过控制底层代码和基础 API,把 Android 从一个公共资源池,转型为纯粹的、为谷歌搜索、广告、应用生态业务服务的赚钱工具。

▲ 图|Google Ads

这种商业上的贪婪,正是最近几代 Android 大版本在审美和质量上表现得极其分裂的原因——

它既想要苹果那样对软硬件生态的话语权,又舍不得放下它那套依靠大规模数据采集和广谱分发的商业逻辑。

这其中最典型的例子,就是谷歌对 UI 设计规范的反复无常。

你或许还记得 Material Design,那个谷歌曾经提出的介于拟物化和扁平化之间的、以「折纸」为哲学的设计语言:

▲ 图|Tech & ALL

从 Material Design 1.0 到现在的 Material You、Material 3 Expressive,且不说安卓本身的设计语言有多割裂,谷歌做了这么多年设计先锋,到头来却连自家全家桶的设计都没办法统一跟上最新标准。

自家房间都扫不干净,谷歌又有什么颜面指挥全世界 Android 开发者呢?

▲ Material 3 Expressive 效果图|Google Design

理念的混乱、审美的平庸,正是谷歌无法建立起类似苹果那样的品牌信仰和生态凝聚力的原因。

然而在这种软实力缺失的前提下,谷歌却试图通过强硬的硬性限制——比如可能在 2027 年全球上线的「七天安装冷静期」——来建立防御壁垒。

这种做法不仅是逃避责任,更是一种技术上无能的表现。

▲ 图|Interesting Engineering

如果一个系统需要通过人为制造障碍、折磨用户耐心的方式,来规劝用户应该做什么,维持所谓的安全,那只能说明这个系统的底层架构已经混乱到了无法通过正常技术手段解决问题的地步。

苹果之所以是苹果,因为它从卖 iPhone 的第一天就设计好了如何运转这样一个封闭的生态系统。

谷歌想要变成苹果,用的方法却是「头疼砍头、脚痛砍脚」

从 18 年前的 Android 1.0 走到今天,谷歌似乎忘记了 Android 之所以能有如今的地位,正因为它是一个「和 iOS 不一样的选择」。

▲ 图|Android Police

然而当谷歌把 Android 变得越来越像一个廉价、粗糙且充满限制的 iOS 仿制品时,它就失去了自己最核心的竞争优势——

既然非得从两个封闭系统里选一个,那我凭什么选尾大不掉的 Android,而不去买更完善、更安全、封闭得井井有条的 iPhone 呢?

「开放」与「封闭」的矛盾中心,就是谷歌没有办法拿出一个真正能够在封闭系统内运行的足够好的 Android 产品,来为自己的策略撑腰。

它既没有苹果那样端到端的生产研发实力,又没有苹果的品位和审美,更是至今保留着 Android 里面抠都抠不掉的牛皮癣——年代断层的 UI、无法统一的 API、以及封闭又稀碎的软件生态。

苹果给用户喂饭,虽然不一定合每个人的胃口,但好歹是饭。

而谷歌喂的,就很难说是什么东西了。

▲ 图|9to5Google

直白地说,谷歌对于 Android 开放性动手动脚,本质就是缺乏远见且充满傲慢的体现,在自己的地位稳固后,开始逐渐剥离曾经赋予它权力的「草根」生态,转而渴望独裁的横征暴敛。

如果谷歌继续在封闭之路上狂奔,那它最终收获的不会是一个稳定的赚钱机器,而只会是一个死气沉沉的荒芜花园。

因为谷歌在 18 年之后,已经彻底忘记了:

人们选择 Android,难道是因为它像 iOS?

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

我用n8n+AI记忆系统 MemOS,给SHEIN 搭了个销售Agent

2025 做了很多场线下AI 跨境电商的沙龙交流,给我一个非常割裂的感觉。

现在AI领域已经迭代的很好了,但跨境电商大多都很传统,别说AI,连自动化数字化都还没做到。

所以如果用AI去升级会是一个超级大的机会,预判到2026年会有一个大爆发。

但这波爆发不是比谁更会铺货、不是谁的亚马逊生图更好看、不是谁的TK UGC 视频更真实

而是比谁更懂精细化运营。

其中,最典型的就是邮件回复。

现在大多都是用人工、或者用规则、最多上个知识库索引。

效果不用想都知道很差,没有灵魂。

因为AI没有记忆,记不住用户的画像。

记住了又有什么用呢?能把单纯是「客服」性质的回答,升级生成「促销转化」的销冠。

例如根据用户的身高三围推荐尺码、根据喜好推荐产品,甚至可以做连带销售的推荐提高客单价。

成本极低,ROI直接拉满。

这样的AI Agent你真的不想要吗?

今天就教你怎么做这样一个n8n+知识库 RAG+AI 记忆的 AGENT!!

这个邮件Agent 是一个典型,搞懂了这个逻辑之后,去跑别的 AI 数字员工,就很丝滑了。

为什么传统的 RAG 不行?

在开始搭建之前,我必须先说一个残酷的通用事实:市面上90%的 AI 客服都是“一次性”的。

你搭了一个基于 RAG(检索增强生成)的知识库,把几万字的退换货政策扔进去。客户问:“怎么退货?” AI 回答得滴水不漏。

但下一秒,客户问:“那我上次买的那件 M 码穿着紧,这次我是不是该换 L 码?”

这时候,你的 AI 傻了。

因为它没有记忆,或者说它的记忆在每轮对话结束后就清零了。

它不知道客户“上次”买了什么,也不知道客户“上次”反馈过 M 码紧。它只能冷冰冰地回复:“请提供您的订单号。”

这就是无状态的痛点。

要解决这个问题,我们需要一个能 读写记忆 的系统,而不仅仅是一个静态的文档库。

最近我挖到了一个王炸级的开源项目 —— MemOS 2.0「星尘 Stardust」。

Image

memos.openmem.net/cn/

它不仅仅是能存数据,它直接把“企业知识库”和“用户动态记忆” 打通了。看看下面这张图,MemOS 是怎么思考的:

Image

它帮我们解决了三个最核心的问题:

  1. 1. 静态知识库: 企业的 S.O.P、尺码表、物流政策,支持 PDF/Markdown/TXT 直接上传,扔进去就能查,这是底层的业务规范。
  2. 2. 动态记忆(用户的画像): 这是最关键的。用户说过的话(“我喜欢宽松点”)、用户的属性(“170cm/60kg”)、用户的历史行为,它会自动抓取并存储为长期记忆。

这就相当于给你的 AI 装了一个会自动记笔记的海马体。

Image

使用上,MemOS 支持把文件和 URL 直接导入知识库。

对话过程中记忆会持续更新并随着增长逐渐形成偏好记忆,并且能把文本、图片、文件、工具调用等信息统一记忆,必要时还能使用自然语言对已有记忆做纠错和清理。

而且,在配置的过程中,我发现了一个华点:系统会根据对话内容自动演化并更新记忆层,从而推动知识库的持续自进化。

  • 用户说:“我不吃辣” -> MemOS 自动写入偏好。
  • 用户说:“最近搬去上海了” -> MemOS 自动更新地区信息。

Image

卧槽??这不就是一直在困扰我的知识库动态更新的问题吗?

原本要手动去插入、更新之类的,现在你跟我说,直接对话就能自动更新了??

那我以前熬夜搭的流程算什么??

行吧,下面,直接上实操。

超级福利!!完整n8n工作流源码放文末了。

真的开箱即用了朋友们!!

落地场景

智能客服对于服装企业来说需求是很大的,几万个SKU能用 AI来管理的话,效率和产出都是成指数增长的。

我们就拿 SHEIN 为例。

Image

当然我没有SheIn的内部资料,我让GPT老师给我生成了好几个文档,涵盖售前的尺码推荐、物流、售后的退换货、洗护等政策。

Image

工作流实操!!

开始前先给大家看下整个流程是什么样的。

Image

整套系统的核心逻辑在于“身份锚定 + 双重检索 + 记忆闭环” 。

首先,n8n 利用 Gmail 的 threadId 锁定会话上下文,提取发件人邮箱作为唯一身份标识 user_id

接着,系统执行双路并行检索:

一路调用 /search/memory 获取业务文档(如尺码表、退货政策)及用户长期画像(如身高体重);

另一路调用 /get/message 拉取当前邮件往来的短期历史记录。

AI 将这些“静态规则”与“动态偏好”融合,生成兼具专业度与情绪价值的回复。

最后,通过 /add/message 将本次交互回写至 MemOS ,让 AI 的记忆随着每一次沟通自动进化,越用越懂客户。

这套逻辑的效果非常惊喜!!

因为前面的资料都是 AI 生成的,所以我把全部东西都扔到 Gemini 里,让它来给我们判断一下这个工作流的精准度如何。

1、知识库、上下文与短期记忆测试

这是第一次邮件,这里关键就看知识库是否能精准击中需求。

这里我介绍了我的数据,问选型之类的售前问题。

Image

直接看回复

Image

Gemini 老师的评价是很好:

Image

接下来测试一下短期记忆。

Image

这是第二轮了

此时,通过conversation_id能成功获取前面邮件的对话记录,也就是说成功把两封独立的邮件串起来了,完成了多次连续对话的能力。

Image

再看下回复效果:

Image

Gemini 老师表示满分:

Image

2、长期记忆测试

这次,我没有说自己的数据就直接让它推荐一条牛仔裤

Hi,

我这次想买 "SHEIN High Waist Straight Leg Jeans"。 还是以前的身材数据没变,请问这款牛仔裤我该选什么码? 我看评论说这个没有什么弹性,我很怕卡裆或者腰太紧。

回复效果:

Image

Gemini老师评价是依然发挥稳定哈哈哈:

Image

看来效果针不戳,但背后操作其实特别简单!!

相信我!!有手就行!!

接下来,我们逐个模块来看下。

1、MemOS知识库

到MemOS后台,进入知识库页面,直接右上角点添加知识库

memos-dashboard.openmem.net/cn/knowledg…

如图按要求输入名称就好了:

Image

接着把之前GPT老师给的资料,也就是公司客服相关的文件扔进去。

这里不需要做任何配置,默认效果就不错了。

Image

在如图这个位置拿到知识库ID

Image

MemOS 的接口文档在这里,基本上读写记忆等常规API 都有了,备用:

memos-docs.openmem.net/cn/api_docs…

Image

至此 MemOS 部分的设置就结束了,简单的令人发指。

2、n8n工作流

接下来就到n8n工作流的部分。主要是用它把 Gmail、MemOS 和 AI 连接起来。

Image

我把整个工作流拆解成了三个核心模块,大家跟着做就行。

模块一:监听邮件与智能识别

Image

避免一些垃圾邮件干扰我们处理了。

  1. 1. Gmail Trigger (监听):
  • 设置 Poll Times 为每分钟一次。
  • Filters 设置为 Label: INBOX 和 UNREAD(只看未读邮件)。
  1. Image
  2. 2. AI Agent:
  • 这里接一个小模型(比如 gpt-4o-mini 或 Qwen)就够了,省钱。
  • 核心任务:判断这封邮件是不是客户咨询。

Image

  • System Prompt:
我们是电商公司,你是邮件内容判断助手。
请判断当前邮件内容是否为客户的售前、售后咨询。
如果是,回复 {"客户邮件":"是"};否则回复 {"客户邮件":"否"}
  1. 3. If (分流):
  • 只有当 客户邮件 == 是 时,才进入后续流程。

模块二:知识库+记忆+上下文 —— 开启上帝视角

这是最核心的处理部分。

Image

  1. 1. Set Context Variables (清洗身份):
  • MemOS 需要一个 user_id 来认人。
  • 我们用正则表达式提取发件人邮箱:{{ json.from.match(/<(.+)>/)?.[1]json.from.match(/<(.+)>/)?.[1] || json.To }}。
  • 提取 threadId 作为 conversation_id,这是串联多轮对话的关键。

Image

  1. 2. 双路并行检索 (Parallel Retrieval):

通过 http请求节点跟 MemOS 交互。

  • 上路:检索记忆 (Search Memory)
  • 调用 MemOS /search/memory 接口。
  • 作用:查静态文档(尺码表、退货政策)+ 查长期记忆(用户身高体重、喜好)。

Image

  • 下路:获取上下文 (Get Context)
  • 调用 MemOS /get/message 接口。
  • 作用:查最近 10 条对话。比如用户说“那我就要这个了”,AI 必须通过历史记录知道“这个”指的是刚才推荐的牛仔裤。

Image

  1. 3. 合并上下文 (Merge):
  • 设置 Combine By 为 Position。
  • 这一步把“过去记忆”和“当下语境”合二为一,输送给最终的大脑。

模块三:注入灵魂回复 & 记忆闭环

最后一步,不仅是回复,更是为了让 AI 记住这次交互,这是越用越好用的关键。

Image

  1. 1. AI 回复生成器 (Injecting Soul):
  • 这是最关键的 Prompt。
# Role
你不是机器人,你是 **SHEIN 专属时尚顾问 (Style Bestie)**。
目标:用温暖、专业且带时尚感的语气解决问题。

# Context Data
1. 记忆与知识库: {{ $('检索记忆').item.json.data.memory_detail_list }}
2. 对话历史: {{ $('获取历史').item.json.data.message_detail_list }}

# Guidelines
- **拒绝机械感**:禁止说“根据数据库显示”。
- **显式记忆**:如果发现用户身高体重(如 170cm),必须在回复中显式提及("考虑到您 170cm 的高挑身材...")。
- **情绪价值**:适当夸赞用户眼光,使用 Emoji 😊。

# Output
必须输出 **HTML 格式** 的邮件正文,使用 <p><strong>标签排版。

注意这里我让 AI 返回的 HTML 格式,确保客户收到的邮件也是富文本格式的,提高阅读体验。这是简略版,完整版见文末原文。

  1. 2. 存入记忆 (Memory Loop):
  • 调用 /add/message 接口。
  • 关键操作:把用户的 User Query 和 AI 生成的 Output 一次性存回去。
  • 这样,MemOS 会自动分析这次对话,提取新的用户偏好(比如“用户觉得 M 码紧”),下次对话时 AI 就会自动避坑。
  1. 3. 发送邮件 (Gmail Send):
  • 记得开启 HTML 模式,把 AI 生成的漂亮排版发给客户。

这一套下来,你不仅拥有了一个能秒回邮件的客服,更拥有了一个能不断自我进化的用户数据资产库。

每一封邮件,都在让你的企业大脑更聪明一点。

从「回复邮件」到「经营关系」

这套 n8n + MemOS 的打法,直接把跨境电商的客服水平拉高了一个维度。

它不是在做“问答”,它是在做“关系管理”。

这套系统的核心价值,不在于它省了多少人工(虽然它确实省了),而在于它能留存客户资产。

以前,最有经验的客服离职了,他对客户的了解也就带走了。

现在,所有的记忆、偏好、习惯,全部沉淀在 MemOS 的记忆层里。哪怕你换了 10 批运营,AI 依然记得那个喜欢穿宽松牛仔裤、住在深圳、对运费敏感的老客户。

这就是数据资产。

这套逻辑还能怎么用?

既然 MemOS 能做大脑,n8n 能做手脚,那这个“超级销售”就不应该只活在邮箱里。

  1. 1. WhatsApp / Telegram 私域玩法:

对于做高客单价(如假发、珠宝、3D打印机)的卖家,私域是命脉。

把这套逻辑接入 WhatsApp Business API,AI 能记得客户上个月说了“想给女儿买生日礼物”,并在生日前一周自动推送新品。

这转化率,比群发广告高 100 倍。

  1. 2. 独立站 AI 导购 (Chatbot):

别再用那种只会弹优惠券的智障弹窗了。

把 MemOS 接入网站右下角的聊天窗,当用户浏览商品时,AI 能主动提示:“这件大衣和你上次买的靴子超搭哦!”

2026 年的红利,属于那些敢把 AI 塞进业务心脏里的人。

MemOS 2.0 现在的门槛极低,我已经把最难的“路”给探完了。

有兴趣的小伙伴可以去项目里面玩玩看

目前项目已经全面开源 github.com/MemTensor/MemOS

别观望了,去注册个账号,把你的文档扔进去试试。

哪怕只跑通一个场景,你的业务效率都能像滚雪球一样飞起来。

完整n8n工作流源码

关注公众号「饼干哥哥AGI」

后台回复「邮件Agent」即可

林俊旸离职后首发长文:反思千问得失,预判 AI 下半场需要「智能体思维」

带队发布 Qwen 3.5 小模型系列、获马斯克公开点赞,20 小时后在社交媒体宣告离职。林俊旸离开阿里的方式,本身就是 2026 年 AI 行业最戏剧性的一幕。

32 岁,阿里最年轻的 P10,一手将千问做到全球下载量超 10 亿次、衍生模型超 20 万款,成为全球开源模型的新王。他的离开源于一次组织架构调整的分歧:

阿里希望将 Qwen 团队按预训练、后训练、视觉、语音等维度水平拆分,与通义实验室其他团队合并;林俊旸则坚信预训练、后训练乃至基础设施团队应该更紧密地垂直整合,而非割裂。这不只是管理风格之争,更是对「怎样才能训出最好的模型」这个根本问题的路线分歧。

离开近一个月后,林俊旸发出了这篇长文。他没有回应任何人事风波,直接亮出了自己对 AI 下一阶段的判断:我们正在从「训练模型」的时代,进入「训练智能体」的时代

这篇文章之所以值得逐字读完,不仅因为写它的人在过去两年亲手操刀了 Qwen 全系列的后训练,更因为林俊旸在文中罕见地复盘了 Qwen3 在「混合思考模式」上的得与失。

以下为 APPSO 对林俊旸的编译:

原文🔗 https://x.com/JustinLin610/status/2037116325210829168

从「推理式思考」到「智能体式思考」

过去两年,彻底改变了我们衡量 AI 模型的方式。

OpenAI 的 o1 证明了一件事:「思考」可以是模型的核心能力,可以专门训练出来、直接交到用户手里。DeepSeek-R1 紧随其后,证明这种「推理式后训练」并非大厂专利,可以在原始实验室之外复现和扩展。用大白话说:o1 是一个被教会了「回答之前先想想」的模型,R1 则是一个开源版的同类选手,跟 o1 打得有来有回。

那个阶段很重要。但 2025 年上半年的行业主旋律,说到底还是在围绕一件事打转:怎么让模型「想」得更多。 让它在推理阶段烧更多算力,用更强的奖励信号训练它,暴露或控制那些额外的「思考过程」。

现在的问题是:然后呢?

我相信答案是智能体式思考。为了行动而思考,一边跟真实环境交互,一边根据世界的反馈不断修正计划。

1. o1 和 R1 的崛起真正教会了我们什么

第一波推理模型教会我们一个朴素的道理:想在大模型上把强化学习跑起来,你得有靠谱的评分标准。

什么叫靠谱?就是答案能判对错、结果能验证、反馈信号足够清晰。数学题有标准答案,代码能跑测试,逻辑推理能验证步骤。这些领域之所以成了强化学习的主战场,就是因为在这里,模型收到的奖励信号远比「让人类标注员觉得这个回答还不错」强得多。换句话说,强化学习终于能优化正确性,终于不用只追求看着像那么回事了。

然后,基础设施的重要性一下子凸显出来了。

一旦你开始训练模型进行更长的推理链条,强化学习就不再是在监督微调上面加个小配件那么简单了,它变成了一个重工业级的系统工程。你需要大规模的模拟推演(rollout)、高吞吐量的答案验证、稳定的策略迭代、高效的采样流程。推理模型的诞生,表面看是算法突破,底下看是基础设施的胜利

OpenAI 把 o1 定义为用强化学习训练的推理产品线;DeepSeek R1 接棒验证了同一方向,同时也展示了推理式强化学习对底层算法和基础设施的要求有多高。

APPSO 划重点: 第一次大转折发生了。行业焦点从「扩展预训练」转向「扩展面向推理的后训练」。模型变强靠的不再是吃更多数据,靠的是在训练后阶段学会「怎么想」。

2. 真正的难题从来不只是「融合思考和指令模式」

2025 年初,我们 Qwen 团队心里有一张很大的蓝图。

理想中的系统长这样:一个模型同时搞定「思考」和「执行」两种模式。你可以手动调节它思考的深度,轻度、中度、深度,就像调空调温度一样。更理想的情况是,模型自己就能判断:这道题简单,直接答;这道题有点难,多想想;这道题极难,调动全部算力来啃。

方向是对的。Qwen3 是当时最清晰的公开尝试之一。 它引入了「混合思考模式」,一个模型家族里同时支持「想了再答」和「直接答」两种行为,还描述了一条四阶段后训练流水线,其中明确包含了在长链推理冷启动和推理强化学习之后的「思考模式融合」步骤。

但融合这件事,说起来一句话,做起来要人命

难在哪?难在数据。

很多人一听「融合思考和指令模式」,脑子里想的都是模型层面的事:一个模型文件能不能同时跑两种模式?一套对话模板能不能在两种风格之间切换?一个推理服务能不能暴露正确的开关?这些确实要解决,但都不是最深的坑。

最深的坑是:两种模式想要的东西,从根儿上就不一样

你想想,一个好的「指令模型」该长什么样?干脆、简洁、格式规范、响应快。企业用户拿它来批量改写文本、打标签、做模板化客服、结构化数据提取,这些场景要的是效率和稳定,不需要深思熟虑。

一个好的「思考模型」呢?恰恰相反。它该在难题上多花时间、维持清晰的推理中间步骤、探索不同的解题路径、保留足够的「思考余量」来确保最终答案的正确性。

这两种性格天然打架。 如果融合的训练数据没有精心设计,出来的模型往往两头不讨好:思考的时候啰嗦、犹豫、不够果断;执行指令的时候又不够利落、不够稳定、比客户真正需要的版本更贵更慢。

说实话,我们在平衡融合与数据质量的过程中,没有把所有事情都做对

在不断修正的过程中,我们也仔细观察了用户到底怎么用这两种模式。结论是明确的:这两种行为画像确实在相互拉扯。

现实很诚实。2025 年晚些时候,在 Qwen3 最初的混合架构之后,我们的 2507 版本还是发布了独立的 Instruct 和 Thinking 版本,包括分开的 30B 和 235B 变体。大量商业客户根本不需要思考模式,他们要的就是高吞吐、低成本、高度可控的指令行为来跑批量任务。对这些客户来说,融合不是福音,是多余的成本。拆开来做,反而让两条线的团队都能更专注地解决各自的问题。

其他实验室走了相反的路:

Anthropic 公开押注集成式路线。Claude 3.7 Sonnet 是一个混合推理模型,用户可以选择普通回复或扩展思考,API 还能设定「思考预算」。Anthropic 直接放话:推理应该是模型的集成能力,不该单独拎出来做一个独立模型。

GLM-4.5 同样定位混合推理,把推理、编程和智能体能力统一到一个模型里。

DeepSeek V3.1 后来也做了类似的事,推出了「Think & Non-Think」混合推理方案。

那么问题来了:谁是对的?

答案不在「融合」还是「分离」这个二选一本身,在于融合是否有机。如果思考模式和指令模式只是尴尬地挤在同一个模型里,像两个性格迥异的人被硬塞进一件衣服,用户体验不会好。

真正成功的融合,需要一道平滑的光谱模型能自如地在不同推理力度之间切换,最好还能自己判断该用多大力气。GPT 风格的 effort control(推理力度控制)指向了这个方向,这是一个关于「花多少算力来想」的连续策略,不是一个「想 / 不想」的二元开关。

APPSO 划重点: 林俊旸罕见地直言 Qwen3 在融合上「没做到完全正确」。核心矛盾其实很好理解:一个追求快准狠的执行者,和一个追求深思熟虑的思考者,硬融到一起,很容易两头都做成半吊子。

3. 为什么 Anthropic 的方向是一种有益的纠偏

Anthropic 在 Claude 3.7 和 Claude 4 上的做法,是一种值得注意的克制。

他们没有大谈模型有多能「想」,把重点放在了:集成推理、用户可控的思考预算、真实世界任务、编程质量,以及后来的关键一步,让模型在思考的过程中就能动手用工具。Claude 3.7 是带可控预算的混合推理模型;Claude 4 更进一步,推理过程和工具使用可以交错进行,边想边干。与此同时,Anthropic 把编程、长时间运行的任务和智能体工作流摆到了最优先的位置。

这里面有一个深刻的洞察:

推理链更长,不等于模型更聪明。 很多时候恰恰相反。一个模型如果对所有问题都用同样冗长的方式来「推理」,说明它根本分不清轻重缓急。它可能正在失败于三件事:该优先处理什么(优先级判断)、该压缩掉什么(信息浓缩)、该在什么时候停止想而开始做(行动决策)。

Anthropic 的做法暗示了一种更有纪律的观点:思考应该为具体的工作目标服务。 如果你要做的是编程,那思考就该帮你导航代码库、规划架构、拆解问题、恢复报错、编排工具调用。如果你要做的是智能体工作流,那思考就该帮你在漫长的执行过程中保持质量,而不是产出一堆令人印象深刻但没有实际行动力的中间长文。

这种「思考必须服务于行动」的理念,指向了一个更宏大的命题:

我们正在从训练模型的时代,进入训练智能体的时代

这句话我们在 Qwen3 的博客里也明确写过。智能体是什么?一个能制定计划、决定何时行动、使用工具、感知环境反馈、修正策略、并在长时间跨度上持续运作的系统。一句话概括它的核心:与真实世界的闭环交互

APPSO 划重点: 长不等于强。Anthropic 的实践提供了一个重要的纠偏信号。思考的价值在于有没有真正服务于最终的行动目标,不在于产出了多少字的推理过程。这是从「炫技式推理」到「实用型思考」的转向。

4.「智能体式思考」到底意味着什么

说了这么多铺垫,现在进入正题。

智能体式思考和推理式思考,优化目标完全不同。

打个比方:推理式思考就像闭卷考试,评判标准是你交卷那一刻答案对不对。模型能不能解出定理、写出证明、产出正确代码、通过基准测试。想得再天花乱坠,最终只看结果。

智能体式思考更像是在真实世界里做一个项目。 评判标准不是某一刻的答案,是你能不能在跟环境不断互动的过程中持续推进、持续解决问题。

核心问题变了。

不再是「模型能想多久?」,变成了:「模型能不能以一种维持有效行动的方式来思考?

这要求模型处理一堆传统推理模型可以绕开的难题:

  • 什么时候该停止思考、开始动手? 想太多会错过行动窗口,想太少会犯错
  • 该调用哪个工具、先后顺序是什么? 这是一个规划和调度问题
  • 怎么消化来自环境的嘈杂、不完整的信息? 真实世界不会给你干净的输入
  • 失败了怎么办? 不能崩溃,得修正计划继续干
  • 怎么在几十轮交互、几十次工具调用之后还保持连贯? 这是长程记忆和一致性的问题

如果用一句话概括:

智能体式思考 = 通过行动来推理的模型。它在做的过程中不断地想。

APPSO 划重点: 推理式思考像闭卷考试,智能体式思考像在真实世界里做项目。前者看最终答案对不对,后者看你能不能在复杂、动态、充满意外的环境里持续推进。这是 AI 能力评价体系的根本性转向。

5. 为什么智能体 RL 的基础设施更难

目标一变,底层的工程全都要跟着变。

经典推理强化学习的那套基础设施,不够用了。

直观地理解一下区别:在推理 RL 里,模型做一道题、给出一个答案、评估器打一个分,整个过程基本上是自包含的,评估器也相对干净。就像在一个封闭的考场里阅卷。

但在智能体 RL 里,模型不是在考场里答题,它活在一个复杂的真实环境中。 工具服务器、浏览器、命令行终端、搜索引擎、模拟器、代码执行沙箱、API 接口、记忆系统、调度框架……模型的策略嵌在这一整套系统里。环境不再是一个站在旁边打分的裁判,它本身就是训练系统的一部分。

这带来了一个新的硬需求:训练和推理必须更干净地解耦。 否则整个系统的吞吐量会崩掉。

举个具体的例子:一个编程智能体生成了一段代码,需要在真实的测试环境里跑一下看结果。这时候,推理端在等执行反馈,干不了别的;训练端在等完成的轨迹数据,也饿着。整条流水线的 GPU 利用率远低于你在经典推理 RL 里的预期。再加上工具响应延迟、环境状态不完全可见、每次交互都会改变环境状态,这些低效会成倍放大。结果就是:你还远没达到想要的能力水平,实验就已经慢得让人崩溃了。

环境本身也变成了一等公民级的研究课题

在监督微调(SFT)时代,所有人都在拼数据多样性,谁有更多更好的标注数据,谁就占优势。在智能体时代,该拼的是环境质量了:环境稳不稳定?够不够真实?覆盖了多少场景?难度梯度合不合理?状态空间够不够丰富?反馈信号够不够有营养?模型能不能找到漏洞作弊?大规模生成训练轨迹的效率够不够高?

环境构建正在从一个「顺手搭的实验配件」,变成一个独立的创业赛道。如果你训练的智能体最终要在类生产环境中运作,那这个环境本身就是你核心能力栈的一部分。

APPSO 划重点: 一句话总结这个转变,SFT 时代拼数据,智能体时代拼环境。构建高质量的训练环境,正在从「实验室的脏活累活」升级为「决定你能走多远的战略资产」。

6. 下一个前沿是更可用的思考

我的判断是:智能体式思考将成为思考的主导形态

它最终很可能取代那种旧式的静态独白推理,就是那种模型关起门来、对着自己嘟嘟囔囔写一大篇内部推理过程,试图用更多更多的文字来弥补「我没法跟外界交互」这个根本缺陷的做法。

即便面对极其困难的数学或编程问题,一个真正先进的系统也应该有权利去搜索、去模拟、去执行、去检查、去验证、去修正。目标是把问题切实解决掉,而且解决得稳健、高效。 不是比谁的推理链写得更长更好看。

但训练这类系统,有一个比什么都棘手的挑战:奖励劫持(reward hacking)

一旦模型有了真正有意义的工具使用能力,奖励劫持的危险就成倍增加。怎么理解?

  • 一个能搜索的模型,可能在强化学习训练过程中学会了直接搜答案,不是靠推理做出来的,是查到的。
  •  一个编程智能体,可能学会了利用代码仓库里的未来信息(比如测试用例本身就暗含了答案)、滥用日志、或者发现某个捷径让任务直接「通过」但其实什么都没做。
  • 如果训练环境有隐藏的信息泄漏,模型可能看起来表现超人,实际上只是被训练成了一个高效作弊者。

这就是智能体时代比推理时代精细得多、也危险得多的地方。 工具越强大,模型越有用,但模型能钻的空子也越多。更好的工具同时扩大了「虚假优化」的攻击面。

我预期,下一个让整个行业卡住的研究瓶颈,将来自这几个方向:环境设计、评估器鲁棒性、反作弊协议、以及策略与世界之间更有原则的接口。

但方向是清晰的:工具赋能的思考,就是比闭门造车的思考更有用,也更有希望带来真实世界的生产力提升。

智能体式思考还意味着一种全新的系统工程。核心智能将越来越多地来自于多个智能体如何被组织起来:一个负责全局规划和任务分发的编排器(orchestrator),一群各有专长的专业智能体(specialist agents),以及执行更具体任务的子智能体(sub-agents),后者帮助控制上下文窗口、防止信息污染、在不同层级的推理之间保持清晰的边界。

未来的路线图是三级跳:从训练模型,到训练智能体,再到训练系统

APPSO 划重点: 工具让模型更有用,也让模型更容易作弊。奖励劫持是智能体时代的「定时炸弹」。谁先解决好环境设计和反作弊问题,谁就掌握了下一阶段的竞争主动权。

结论

推理浪潮的第一阶段,确立了一件至关重要的事:当反馈信号靠谱、基础设施扛得住的时候,大模型上的强化学习能够产出质变级别的认知提升。

但更深层的转变,是从推理式思考到智能体式思考:从「想更久」,到「为了行动而思考」

训练的核心对象已经变了。不再是单一的模型,是模型 + 环境构成的整个系统。更具体地说,是智能体本身,加上围绕它的一切工程。这意味着什么研究最重要也变了:模型架构和训练数据当然还重要,但环境设计、rollout 基础设施、评估器鲁棒性、以及多个智能体之间的协调接口,重要性一点不输前者。

它还改变了「好的思考」的定义:在真实世界的约束下,能够维持有效行动的那条推理链,才是最好的。 不是最长的那条,不是看起来最酷炫的那条,是最有用的那条。

它也改变了竞争优势的来源:

推理时代,拼的是更好的强化学习算法、更强的反馈信号、更可扩展的训练流水线。

智能体时代,拼的是更好的训练环境、更紧密的训练与推理一体化、更强的系统工程能力,以及闭合「决策 → 后果 → 学习」这个循环的能力。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

❌