告别死板的 Markdown:在 AI 流式回复中嵌入动态 UI 组件

46 阅读6分钟

先聊个场景:前段时间体验千问免费请客的功能,真的被那种丝滑的交互惊艳到了——聊着聊着,它直接甩出一个商品卡片,图片、价格、购买按钮一应俱全,点一下就能下单,整个过程行云流水。当时我就想:这到底是怎么做到的?

于是我去 GitHub 上翻了翻,想看看有没有现成的轮子。还真让我找到了——Ant Design X 的 @ant-design/x-markdown,它正好解决了“流式渲染”和“自定义组件”的问题,设计得很干净。但仔细一看,它是 React + Ant Design 深度绑定的,而我的项目是 Vue 2.5 + Element UI,完全没法直接用。要硬用就得包一层 React 运行时,成本太高。

既然现成的轮子用不上,我就决定读它的源码,复用它的“解析思路”,再做一个真正通用的方案。于是就有了 x-langjs——一个让AI能在回复里“画”出UI组件的小脚本语言。


先看开源:@ant-design/x-markdown

比较符合我需求的是 Ant Design X 的 @ant-design/x-markdown。它解决了“流式渲染”和“自定义组件”的问题,设计得很干净。

但有一个致命点:它是 React + Ant Design 深度绑定。我的项目是 Vue 2.5 + Element UI,在工程形态和渲染方式上完全不匹配。要硬用就得包一层 React 运行时,成本太高。

所以我决定读它的源码,复用它的“解析思路”,再做一个真正通用的方案。


读源码:核心解析思路是什么?

下面这段代码来自 @ant-design/x-markdown 的真实源码,它的解析思路非常值得参考。

1. Markdown 解析:用 marked + 自定义渲染器

Parser.ts 里通过 marked 统一解析,然后在 code 渲染中埋入流式状态:

const renderer = {
  code({ text, raw, lang, escaped, codeBlockStyle }: Tokens.Code): string {
    const infoString = (lang || '').trim();
    const langString = infoString.match(other.notSpaceStart)?.[0];
    const code = `${text.replace(other.endingNewline, '')}\n`;
    const isIndentedCode = codeBlockStyle === 'indented';
    const streamStatus =
      isIndentedCode || other.completeFencedCode.test(raw) ? 'done' : 'loading';
    const escapedCode = escaped ? code : escapeHtml(code, true);

    const classAttr = langString ? ` class="language-${escapeHtml(langString)}"` : '';
    const dataAttrs =
      ` data-block="true" data-state="${streamStatus}"` +
      (infoString ? ` data-lang="${escapeHtml(infoString)}"` : '');

    return `<pre><code${dataAttrs}${classAttr}>${escapedCode}</code></pre>\n`;
  },
};

这里的关键是:流式状态不是靠“外部判断”,而是直接体现在 HTML 里。如果 fenced code 还没闭合,data-state="loading" 会被写进去,后续渲染时就能识别“半成品”。

另一个很实用的小技巧:在 protectCustomTags 里处理自定义组件内的空行,防止被 Markdown 解析器拆段。

private protectCustomTags(content: string): {
  protected: string;
  placeholders: Map<string, string>;
} {
  const placeholders = new Map<string, string>();
  const customTagNames = Object.keys(this.options.components || {});
  // ...
  if (innerContent.includes('\n\n')) {
    const protectedInner = innerContent.replace(/\n\n/g, () => {
      const ph = `__X_MD_PLACEHOLDER_${placeholderIndex++}__`;
      placeholders.set(ph, '\n\n');
      return ph;
    });
    result.push(openTag + protectedInner + closeTag);
  }
  // ...
}

思路总结:

  • Markdown 解析交给成熟工具(marked)。
  • 流式状态用 data-state 持久化到输出 HTML。
  • 自定义组件的内容要“保护”,避免被 Markdown 拆解。

2. 渲染层:DOM 清洗 + 组件替换 + 识别未闭合标签

渲染层在 Renderer.ts,核心三步:

  1. 检测未闭合标签,用于流式 loading;
  2. DOMPurify 清洗;
  3. 把自定义标签替换成 React 组件。
private detectUnclosedTags(htmlString: string): Set<string> {
  const unclosedTags = new Set<string>();
  const stack: string[] = [];
  const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>/g;

  let match = tagRegex.exec(htmlString);
  while (match !== null) {
    const [fullMatch, tagName] = match;
    const isClosing = fullMatch.startsWith('</');
    const isSelfClosing = fullMatch.endsWith('/>');

    if (this.options.components?.[tagName.toLowerCase()]) {
      if (isClosing) {
        const lastIndex = stack.lastIndexOf(tagName.toLowerCase());
        if (lastIndex !== -1) {
          stack.splice(lastIndex, 1);
        }
      } else if (!isSelfClosing) {
        stack.push(tagName.toLowerCase());
      }
    }
    match = tagRegex.exec(htmlString);
  }

  stack.forEach((tag) => {
    unclosedTags.add(tag);
  });
  return unclosedTags;
}

这段逻辑很好用:流式输出时自定义标签没闭合也不崩,而是标记成 loading,再交给组件处理。

思路总结:

  • HTML 进入 React 之前先用 DOMPurify 过滤。
  • 自定义标签用 replace 机制“换成组件”。
  • 未闭合标签识别后可用于“占位渲染”。

这些思路非常清晰,但它仍然是 React 的思路:domToReact + createElement。我的 Vue 项目很难复用这套逻辑。于是就有了 x-langjs


x-langjs 到底是什么

简单说,就是让 AI 在 Markdown 里写类似 button("点我") 这样的代码,然后你提前用 JavaScript 定义好 button 长啥样、怎么渲染。前端拿到 AI 流式吐出来的内容,解析这些代码块,找到对应的组件,渲染到页面上。

整个过程对用户来说完全无感,他们只看到 AI 回复里多了个能点的按钮。


先展示神力

安装:

npm install @x-langjs/core
# 或者用 pnpm
pnpm add @x-langjs/core

然后注册一个按钮组件:

import { XLangApp, defineComponent } from '@x-langjs/core';

const app = new XLangApp();

app.use(defineComponent('button', {
  // setup 接收 DSL 里的参数,返回组件需要的 props
  setup: (args) => ({
    text: String(args[0] ?? '默认按钮'),
    type: String(args[1] ?? 'primary')
  }),
  // render 负责把 props 渲染到容器里
  render(data, container) {
    const btn = document.createElement('button');
    btn.textContent = data.text;
    btn.className = `btn-${data.type}`;
    container.appendChild(btn);
    // 可选:返回 dispose 函数清理资源
    return {
      dispose: () => btn.remove()
    };
  }
}));

现在,让 AI 输出这样一段 Markdown:

普通文字...

```x-langjs
button("立即购买", "danger")
```

后面还有文字。

然后你调用:

app.run(markdown, document.getElementById('root'));

页面上就会渲染出一个红色(danger)的“立即购买”按钮。


复杂玩法:构建一个订单卡片组件(Vue + Element UI)

光看按钮不过瘾,我们来点真东西:用 x-langjs 配合 Vue 2 和 Element UI,让 AI 输出一个带交互的订单卡片。

完整 HTML 代码(可直接运行)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>x-langjs 订单卡片示例</title>
    <link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
    <style>
        body { font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; padding: 20px; background: #f0f2f5; }
        #output { margin: 20px 0; }
        .log-panel { background: #fff; padding: 15px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
        .log-panel pre { margin: 10px 0 0; padding: 10px; background: #f8f8f8; border-radius: 4px; }
    </style>
</head>
<body>
    <h2>x-langjs 复杂 demo - 订单卡片(Element UI)</h2>
    <p>AI 输出 <code>```x-langjs orderCard("ORD-123", 299.99, "已发货") ```</code>,动态渲染如下:</p>
    <div id="output"></div>

    <div class="log-panel">
        <strong>事件日志:</strong>
        <pre id="event-log">等待卡片交互...</pre>
    </div>

    <!-- 引入依赖 -->
    <script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
    <script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
    <script src="https://unpkg.com/@x-langjs/core@0.0.5/dist/x-langjs.min.js"></script>

    <script>
        (function() {
            const outputEl = document.getElementById('output');
            const logEl = document.getElementById('event-log');

            const { XLangApp, defineComponent } = XLang;
            const app = new XLangApp();

            // 注册 orderCard 组件
            app.use(defineComponent('orderCard', {
                setup: (args) => ({
                    orderId: String(args[0] || '未知订单'),
                    amount: Number(args[1] || 0),
                    status: String(args[2] || '待处理')
                }),
                render(data, container, ctx) {
                    // 创建 Vue 实例,用 render 函数生成 Element UI 卡片
                    const vm = new Vue({
                        data: {
                            orderId: data.orderId,
                            amount: data.amount,
                            status: data.status
                        },
                        methods: {
                            handleConfirm() {
                                ctx.emit('confirm', {
                                    orderId: this.orderId,
                                    message: `订单 ${this.orderId} 确认收货`
                                });
                            }
                        },
                        render(h) {
                            return h('el-card', { props: { shadow: 'hover' } }, [
                                h('div', { slot: 'header' }, [
                                    h('span', { style: { fontWeight: 'bold' } }, '订单详情'),
                                    h('el-tag', {
                                        props: {
                                            type: this.status === '已发货' ? 'success' : 'warning',
                                            size: 'small',
                                            style: 'float: right;'
                                        }
                                    }, this.status)
                                ]),
                                h('div', { style: 'margin-bottom: 10px;' }, [
                                    h('p', { style: 'margin: 5px 0;' }, `订单号:${this.orderId}`),
                                    h('p', { style: 'margin: 5px 0;' }, `金额:¥${this.amount.toFixed(2)}`)
                                ]),
                                h('div', { style: 'text-align: right;' }, [
                                    h('el-button', {
                                        props: { type: 'primary', size: 'small', disabled: this.status !== '已发货' },
                                        on: { click: this.handleConfirm }
                                    }, '确认收货')
                                ])
                            ]);
                        }
                    });

                    vm.$mount(container);
                    return {
                        dispose: () => {
                            vm.$destroy();
                            container.innerHTML = '';
                        }
                    };
                }
            }));

            // 监听 confirm 事件
            app.on('orderCard', 'confirm', (payload) => {
                logEl.textContent = `✅ ${payload.message} (时间: ${new Date().toLocaleTimeString()})`;
                console.log('事件收到:', payload);
            });

            // Markdown 内容
            const markdownContent = `
### 这里是 markdown 渲染逻辑

\`\`\`x-langjs
orderCard("ORD-123", 299.99, "已发货")
\`\`\`

点击“确认收货”按钮,下方日志会记录事件。
            `;

            app.run(markdownContent, outputEl);
        })();
    </script>
</body>
</html>

在上面的例子里,render 函数里用了很多 Vue 的 h 方法(也就是 createElement),这确实会让人觉得“这么复杂,正常项目也要这么写吗?”

其实不是的。h 函数只是 Vue 提供的一种灵活构建虚拟 DOM 的方式,但绝不是唯一的方式。 x-langjs 的 render 函数只要求你把内容渲染到传入的 container 这个 DOM 元素里,至于你怎么渲染,完全自由。


你可以用任何你喜欢的方式

1. 直接用原生 DOM 操作

如果组件很简单,直接用 document.createElementappendChild 就行,就像最开始的按钮示例那样:

render(data, container) {
  const btn = document.createElement('button');
  btn.textContent = data.text;
  container.appendChild(btn);
}

2. 使用 Vue 的单文件组件(.vue)

在真实项目中,你大概率会把组件写成 .vue 文件(模板 + 脚本 + 样式)。然后在 render 里动态创建这个组件的实例并挂载:

import MyOrderCard from './components/MyOrderCard.vue';

render(data, container, ctx) {
  const ComponentClass = Vue.extend(MyOrderCard);
  const instance = new ComponentClass({
    propsData: { ...data },
    // 可以监听事件
    listeners: {
      confirm: (payload) => ctx.emit('confirm', payload)
    }
  });
  instance.$mount(container);
  return { dispose: () => instance.$destroy() };
}

这样你的组件代码就和普通 Vue 组件完全一样,不需要写 h 函数。

3. 使用 JSX(如果项目配置了)

如果你喜欢 JSX 的写法,也可以在 render 里直接用:

render(data, container) {
  const element = <el-card shadow="hover">...</el-card>;
  // 需要借助 Vue 的渲染函数把 JSX 转成真实 DOM
  const vm = new Vue({
    render: h => element
  });
  vm.$mount(container);
}

4. 甚至可以用其他框架

x-langjs 不挑食,你想用 React、Preact 甚至 jQuery 都行,只要最后把内容放进 container 即可。

核心逻辑拆解

  1. 注册组件:通过 defineComponent 定义 orderCardsetup 将 DSL 参数转为 props,render 中动态创建 Vue 实例,使用 Vue 的 render 函数生成 Element UI 卡片。
  2. 事件传递:卡片内的“确认收货”按钮点击时,调用 ctx.emit('confirm', payload) 将事件发送到 x-langjs 应用层。
  3. 监听事件app.on('orderCard', 'confirm', ...) 接收事件并更新页面日志,实际开发中可以在这里调用后端 API。
  4. DSL 调用:Markdown 中包含 ```x-langjs orderCard("ORD-123", 299.99, "已发货") ```,运行后渲染出卡片。

这个示例展示了 x-langjs 真正的威力:AI 动态生成 UI,而你用自己熟悉的框架(Vue)来实现这些 UI,事件也能双向沟通。不需要改造现有项目,只需要写几行胶水代码,就能让 AI 输出活起来的组件。


核心玩法

1. 用任何框架渲染组件

x-langjs 的 render 函数只给一个 DOM 容器,你爱用啥框架就用啥。

用 Vue:

import { h, render as vueRender } from 'vue';
import MyButton from './MyButton.vue';

defineComponent('button', {
  setup: (args) => ({ text: String(args[0]) }),
  render(data, container) {
    // Vue 的 render 会替换 container 的内容
    vueRender(h(MyButton, { text: data.text }), container);
  }
});

用 React:

import { createRoot } from 'react-dom/client';
import { Button } from 'antd';

defineComponent('button', {
  setup: (args) => ({ text: String(args[0]) }),
  render(data, container) {
    const root = createRoot(container);
    root.render(<Button>{data.text}</Button>);
    // React 18 需要手动 unmount
    return {
      dispose: () => root.unmount()
    };
  }
});

2. 监听 UI 事件

组件可以往回发消息,用 ctx.emit

组件定义:

defineComponent('button', {
  setup: (args) => ({ text: String(args[0]) }),
  render(data, container, ctx) {
    const btn = document.createElement('button');
    btn.textContent = data.text;
    btn.onclick = () => ctx.emit('click', { text: data.text });
    container.appendChild(btn);
  }
});

外面监听:

app.on('button', 'click', (payload) => {
  console.log('按钮点了:', payload.text);
  // 可以调接口、更新状态等
});

3. 流式渲染

AI 是一点一点往外吐的,你只需要不断调用 app.run 传入累加的内容,引擎会自动对比差异,只更新变化的部分。

let accumulated = '';
for await (const chunk of aiStream) {
  accumulated += chunk;
  app.run(accumulated, container);
}

如果某个组件还没闭合(比如还在接收参数),引擎会显示一个默认的“加载中...”占位,等完整了再替换成真正的组件。

4. 多轮对话管理

每个 AI 回复应该是独立的,避免互相干扰。x-langjs 提供了一个 ConversationRenderer 来帮你管理多条消息。

import { ConversationRenderer } from './conversation-renderer'; // playground 里有现成的

const renderer = new ConversationRenderer(chatContainer, {
  components: [buttonComponent, cardComponent]
});

// AI 开始回复新消息
const msg = renderer.addMessage('msg-1');
msg.update(partialMarkdown);  // 流式更新
msg.finalize();               // 完成

// 下一条消息
const msg2 = renderer.addMessage('msg-2');
...

每条消息背后都是一个独立的 XLangApp 实例,组件定义是共享的,但执行环境隔离。

5. 覆盖默认渲染器

x-langjs 内置了 Markdown 渲染(简单的 innerHTML)和占位渲染。你可以替换成自己喜欢的。

比如用 marked 来渲染 Markdown:

import { marked } from 'marked';
import { XLangApp } from '@x-langjs/core';

const app = new XLangApp({
  createMarkdownRenderer() {
    return {
      render(content, container) {
        container.innerHTML = marked.parse(content);
        return {
          dispose: () => { container.innerHTML = ''; }
        };
      }
    };
  }
});

计算逻辑:DSL 能算,真正的计算在 setup

x-langjs 的 DSL 语法目前支持函数调用和字面量(字符串、数字、布尔值),同时也支持一些简单的表达式运算,比如数学计算、字符串拼接等。这些运算会在解释器里直接求值,然后把结果作为参数传给组件的 setup

例如,DSL 里可以写:

button("总价: " + (19.99 * 2), "primary")

解释器会先算出 19.99 * 239.98,再和字符串拼接成 "总价: 39.98",最后作为第一个参数传给 buttonsetup

但真正灵活的是 setup 本身——它就是一个普通的 JavaScript 函数,你可以在里面做任何计算。比如根据用户权限决定按钮是否禁用:

defineComponent('actionButton', {
  setup: (args) => {
    const action = String(args[0]);
    const hasPermission = checkPermission(action); // 调用外部函数
    return {
      action,
      disabled: !hasPermission
    };
  },
  render(data, container) {
    const btn = document.createElement('button');
    btn.textContent = data.action;
    btn.disabled = data.disabled;
    container.appendChild(btn);
  }
});

再比如,你可以在 setup 里根据参数组合计算出新的 props,甚至调用工具函数格式化数据:

defineComponent('price', {
  setup: (args) => {
    const value = Number(args[0]);
    const currency = String(args[1] ?? 'CNY');
    const symbol = currency === 'USD' ? '$' : '¥';
    return {
      formatted: `${symbol}${value.toFixed(2)}`,
      raw: value,
      currency
    };
  },
  render(data, container) {
    const span = document.createElement('span');
    span.className = 'price';
    span.textContent = data.formatted;
    container.appendChild(span);
  }
});

DSL 调用:

```x-langjs
price(19.99, "USD")
```

渲染结果:$19.99

这样设计的好处是:DSL 保持简洁,只负责声明要调用什么组件、传什么参数;而真正的业务逻辑、数据转换都放在开发者可控的 JavaScript 代码里,安全又灵活。


项目设计:拆成多个小包,各干各的

x-langjs 用了 monorepo 结构,分成好几个包,每个包有独立的职责:

packages/
  types/        # 共享类型定义(AST 节点、错误类等)
  parser/       # ANTLR4 生成的解析器,把 DSL 代码转成 CST
  ast/          # CST -> AST 转换,作用域解析
  interpreter/  # 遍历 AST 执行,对表达式求值,调用 setup
  render/       # 渲染引擎接口 + 默认实现(Markdown、pending)
  core/         # 给用户用的统一入口,整合上面所有包
playground/     # Vite 演示应用,各种 demo

每个包都可以独立替换。比如你觉得默认的 Markdown 渲染太简单,想换成 marked,那就自己实现个渲染器传进去;甚至你可以重写整个解释器,只要接口对得上。

这种分层设计也让代码更容易测试和维护。每个包只关注一件事,比如 parser 只负责解析语法,interpreter 只负责执行,render 只负责渲染。


总结

x-langjs 就是想让 AI 的输出从“只能看”变成“可以点”。它通过一个极简的 DSL 把组件的“声明”和“实现”分开,AI 只负责声明,开发者负责实现。DSL 本身支持简单的表达式,但真正的计算能力都留给 setup 函数,这样既灵活又安全。

如果你也在做 AI 对话类的应用,想让 AI 能动态生成可交互的 UI,不妨试试 x-langjs。项目完全开源,仓库里有详细的文档和可运行的 playground 示例,上手很容易。

GitHub 地址: github.com/chesongsong…