Skip to content

vtAIStream 流式表格

一边收数据一边渲染,列自己长出来。

现有表格库都隐含两条假设:列是预先定义好的数据一次性给全。AI 应用的输出恰好两条都 违反 —— agent 抓回来的数据事前不知道 schema,模型在逐行往外吐,量还可能很大。这个插件补的 就是这两件事。

三个缺口

缺口处理
逐行 appendRows = 逐行走一遍数据管线攒进缓冲,一帧只落一次flushInterval 可改成定时器)
收到新行时视口该不该跟着走stick-to-bottom:在底部就跟随,用户一上滚就脱离,滚回底部自动恢复
列不知道长什么样首批数据到达时 inferColumns() 推断类型、标题、宽度、对齐

快速上手

ts
import { VirtTable } from '@virt-table/vanilla';
import { vtAIStream } from '@virt-table/vanilla/plugins';
import '@virt-table/vanilla/core.css';
import '@virt-table/vanilla/plugins/ai-stream.css';

const table = new VirtTable(el, {
  list: [],
  columns: [],          // 留空 —— 列由首批数据推断
  itemKey: 'id',
  plugins: [vtAIStream()],
});

// 吃任何 AsyncIterable:SSE、ReadableStream、异步生成器都行
await table.consumeAIStream(rowsFromSSE('/api/rows'));

consumeAIStream 的每个 yield 是一行或一批,两种都接受。手动推也可以:

ts
table.startAIStream();
table.pushAIStreamRows([row1, row2]);   // 攒到下一帧
table.pushAIStreamRows(row3);           // 单行不必自己包数组
table.endAIStream();              // 落掉缓冲里的尾巴

接 SSE

ts
async function* rowsFromSSE(url: string) {
  const res = await fetch(url);
  const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
  let buf = '';
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += value;
    const lines = buf.split('\n');
    buf = lines.pop()!;                       // 最后一段可能是半行,留到下一轮
    for (const line of lines) {
      if (line.startsWith('data: ')) yield JSON.parse(line.slice(6));
    }
  }
}

列推断

inferColumns() 是纯逻辑,也能单独引(@virt-table/vanilla/infer-columns,零 CSS 零 DOM), 所以服务端可以先把列算好随数据一起下发:

ts
import { inferColumns } from '@virt-table/vanilla/infer-columns';

const columns = inferColumns(rows, { itemKey: 'id' });

推断规则(按多数票判定,不看第一行 —— agent 数据的第一行常常是 null 或异常值):

值的样子推断结果
number右对齐 + cellType: 'number'(千分位)+ 可排序
boolean居中,宽 90
ISO 日期串 / Date可排序(不右对齐)
图片 URL(.png / .jpg …)cellType: 'image',宽 80
普通 URL按文本,不可排序
超过 60 字符textOverflow: 'tooltip'
纯数字字符串按文本 —— 它可能是订单号 / 手机号 / 邮编,右对齐加千分位反而是错的
  • 字段集合取所有采样行的并集(不同来源拼起来的数据键可能不齐),列序跟随字段首次出现的顺序。
  • _ 开头的字段与 itemKey 不建列。
  • 列宽取内容 P90 显示宽度与标题宽度的较大者(用 P90 而不是最大值:一两行超长内容不该把整列撑到 320px,那种情况交给 tooltip)。中文按双宽计。
  • 标题走 humanizeKeycreatedAtCreated Atuser_nameUser NameSKU_CODE 原样。要中文标题传 titleOf

推断是尽力而为,产出的是「能直接用的起点」而不是最终答案 —— 拿到结果再改就行。

Options

参数类型默认说明
flushInterval'raf' | number'raf'缓冲落地节奏。一帧一次已经够 —— 模型吐得再快,人眼也只看得见 60fps
maxRowsPerFlushnumber500单次落地上限,超出的留到下一次;防止一批两万行把一帧撑爆
autoColumnsboolean | 'ifEmpty''ifEmpty''ifEmpty' = 表格当前没有列才推断;true = 每轮 startAIStream() 后重新推断
inferOptionsInferColumnsOptions透传给 inferColumns()itemKey 自动带上表格的)
followBottombooleantrue是否自动跟随底部
followThresholdnumber40判定「在底部」的容差(px)。动态行高下总高会被 ResizeObserver 持续修正,判定得留余量
indicatorbooleantrue右下角的接收指示
onFlush(rows, state) => void每次落地后触发
onStateChange(state) => void状态变化时触发

方法

方法说明
consumeAIStream(source, opts?)消费一个 AsyncIterableopts.signal 可取消,opts.autoEnd 默认 true
startAIStream()开始新一轮,重置计数与跟随态(不清数据,清不清由你 setList([]) 决定)
pushAIStreamRows(rows)推入一行或一批
endAIStream()结束接收,同步落掉尾巴
abortAIStream(err?)中断;已收到的行保留(半截数据也是数据)
getAIStreamState(){ status, received, buffered, following, error }
isAIStreamFollowingBottom() / setAIStreamFollowBottom(on)跟随态读写

两点说明

  • 没显式 startAIStream() 就直接 pushAIStreamRows() 也算开始,少一步仪式。
  • 源抛错时 consumeAIStream 会 reject 并把状态置为 error,但不回滚已落地的行

示例

微应用尚未挂载。

关于这个 demo

表格初始 columns: [],列是首批数据到达时推断出来的 —— 注意金额右对齐带千分位、结清居中、 而订单号虽然是数字串却按文本左对齐。接收中往上滚会脱离底部跟随(指示器上的点停止呼吸), 滚回底部自动恢复。「灌 2 万行」验证 maxRowsPerFlush 会自动分批,不会把一帧撑爆。

源码

点击查看源码
ts
import { VirtTable } from '@virt-table/vanilla';
import { vtAIStream } from '@virt-table/vanilla/plugins';

/**
 * vtAIStream:一边收数据一边渲染,列自己长出来。
 *
 * 这个 demo 模拟的是「agent 跑完一段任务,把结果一行一行吐回来」——
 * 表格初始**没有任何列配置**(`columns: []`),首批数据到达时由
 * `inferColumns()` 推断出列(类型、标题、宽度、对齐都是算出来的)。
 *
 * 换成真实数据源只需替换这个生成器,比如吃 SSE:
 *
 * ```ts
 * async function* rowsFromSSE(url: string) {
 *   const res = await fetch(url);
 *   const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
 *   let buf = '';
 *   for (;;) {
 *     const { value, done } = await reader.read();
 *     if (done) break;
 *     buf += value;
 *     const lines = buf.split('\n');
 *     buf = lines.pop()!;
 *     for (const line of lines) {
 *       if (line.startsWith('data: ')) yield JSON.parse(line.slice(6));
 *     }
 *   }
 * }
 * await table.consumeAIStream(rowsFromSSE('/api/rows'));
 * ```
 */

const FIRST_NAMES = ['林', '陈', '王', '李', '张', '刘', '黄', '周', '吴', '徐'];
const GIVEN = ['明', '静', '磊', '娜', '强', '敏', '杰', '芳', '涛', '燕'];
const CITIES = ['上海', '北京', '深圳', '杭州', '成都', '广州', '武汉', '西安'];
const CHANNELS = ['官网', '小程序', '门店', '电话', '第三方平台'];
const STATUS = ['已完成', '处理中', '待审核', '已取消'];

/** 一条「agent 抓回来的」记录。注意 key 是 camelCase,标题由 humanizeKey 推出来 */
function makeRow(i: number) {
  const rnd = (n: number) => Math.floor(Math.random() * n);
  return {
    id: i,
    orderNo: `SO${String(202600000 + i)}`, // 纯数字串:不该被当成数字右对齐
    customerName: FIRST_NAMES[rnd(FIRST_NAMES.length)] + GIVEN[rnd(GIVEN.length)],
    city: CITIES[rnd(CITIES.length)],
    channel: CHANNELS[rnd(CHANNELS.length)],
    amount: rnd(980000) + 2000, // 数字:应当右对齐 + 千分位
    settled: Math.random() > 0.4, // 布尔:应当居中
    createdAt: new Date(Date.now() - rnd(86400000 * 90)).toISOString().slice(0, 10), // 日期
    status: STATUS[rnd(STATUS.length)],
  };
}

export function bootstrapTableAIStream(root: HTMLElement): () => void {
  root.innerHTML = `
    <div class="demo-toolbar" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px;">
      <button type="button" id="btnStart" style="cursor:pointer;font-size:13px;padding:4px 14px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">开始接收</button>
      <button type="button" id="btnStop" style="cursor:pointer;font-size:13px;padding:4px 14px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">停止</button>
      <button type="button" id="btnBurst" style="cursor:pointer;font-size:13px;padding:4px 14px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">灌 2 万行</button>
      <button type="button" id="btnReset" style="cursor:pointer;font-size:13px;padding:4px 14px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">清空重来</button>
      <span id="streamStat" style="font-size:12px;opacity:.7;margin-left:4px;font-variant-numeric:tabular-nums;"></span>
    </div>
    <div style="font-size:12px;opacity:.6;margin-bottom:8px;">
      表格初始<strong>没有列配置</strong>,列是首批数据到达时推断出来的(金额右对齐 + 千分位、结清居中、
      订单号虽是数字串但不右对齐)。接收中往上滚会脱离底部跟随,滚回底部自动恢复。
    </div>
    <div style="width:100%;height:520px;" class="demo-container" id="tableContainer"></div>
  `;
  const container = root.querySelector('#tableContainer') as HTMLElement;
  const statEl = root.querySelector('#streamStat') as HTMLElement;

  const table = new VirtTable(container, {
    list: [],
    columns: [], // 故意留空 —— 列由 vtAIStream 推断
    itemKey: 'id',
    estimatedSize: 40,
    buffer: 6,
    border: true,
    emptyText: '等待数据…点上方「开始接收」',
    plugins: [
      vtAIStream({
        // 给数字列配上千分位(inferColumns 推出 cellType: 'number' 后由核心渲染)
        inferOptions: { minWidth: 96 },
        onStateChange(state) {
          statEl.textContent =
            state.status === 'idle'
              ? ''
              : `${state.status} · 已落地 ${state.received.toLocaleString()} 行` +
                (state.buffered > 0 ? ` · 缓冲 ${state.buffered}` : '') +
                (state.following ? '' : ' · 已脱离跟随');
        },
      }),
    ],
  });

  let seq = 0;
  let stopped = false;
  let timer: ReturnType<typeof setTimeout> | null = null;

  /** 模拟「模型逐条吐结果」:每 40ms 来 1~6 行,节奏不均匀 */
  const startTrickle = () => {
    stopped = false;
    table.startAIStream();
    const step = () => {
      if (stopped) return;
      const n = 1 + Math.floor(Math.random() * 6);
      table.pushAIStreamRows(Array.from({ length: n }, () => makeRow(seq++)));
      timer = setTimeout(step, 30 + Math.random() * 60);
    };
    step();
  };

  const stop = () => {
    stopped = true;
    if (timer) clearTimeout(timer);
    timer = null;
    table.endAIStream();
  };

  root.querySelector('#btnStart')!.addEventListener('click', () => {
    if (!stopped && timer) return;
    startTrickle();
  });
  root.querySelector('#btnStop')!.addEventListener('click', stop);

  // 一次灌很多:验证批量落地不会把一帧撑爆(maxRowsPerFlush 会分批)
  root.querySelector('#btnBurst')!.addEventListener('click', () => {
    table.pushAIStreamRows(Array.from({ length: 20000 }, () => makeRow(seq++)));
  });

  root.querySelector('#btnReset')!.addEventListener('click', () => {
    stop();
    seq = 0;
    table.setList([]);
    table.setColumns([]);
    table.startAIStream();
    statEl.textContent = '';
  });

  return () => {
    stopped = true;
    if (timer) clearTimeout(timer);
    table.destroy();
    root.innerHTML = '';
  };
}