Skip to content

vtAIColumn AI 列

一列的值由模型从同行其他列算出来,边算边写。

虚拟化在这件事上是决定性优势

一列 AI 值在 10 万行表上全量跑一遍,是 10 万次模型调用;而屏幕上同时只有二十来行。 所以这个插件默认只为渲染窗口内的行付钱 —— 滚到哪算到哪,滚走了还没开始的直接出队、 已在途的 abort()

不虚拟化的表格做不到这个优化:它们的 DOM 里所有行都在,没有「可见」这个概念可用。

快速上手

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

const table = new VirtTable(el, {
  columns,
  list,
  plugins: [
    vtAIColumn({
      column: { key: 'summary', title: 'AI 摘要', width: 280 },
      async compute(row, { signal }) {
        const r = await fetch('/api/summarize', {
          method: 'POST',
          body: JSON.stringify({ text: row.content }),
          signal,           // 行滚出视口时会 abort,务必透传
        });
        return (await r.json()).text;
      },
    }),
  ],
});

插件会把 column 追加到列树末尾;列树里已有同 key 的列则直接接管那一列的渲染 (想把 AI 列放中间,自己在 columns 里先占好位置)。

流式写入

compute 返回 AsyncIterable<string> 就逐段写进单元格 —— 模型流式输出时一个字一个字往外冒, 比转圈等着好看得多:

ts
vtAIColumn({
  column: { key: 'summary', title: 'AI 摘要', width: 280 },
  async *compute(row, { signal }) {
    const res = await fetch('/api/summarize', {
      method: 'POST',
      body: JSON.stringify({ text: row.content }),
      signal,
    });
    const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      yield value;
    }
  },
})

每段只重画那一格td.textContent),不走整帧 forceUpdate —— 逐字更新时那个开销无法接受。

值不写回行数据

生成的值存在插件自己的缓存里,按 rowKey 索引。行数据是你的,AI 生成的东西混进去之后就 分不清哪些是原始数据了(导出、提交、对比都会踩坑)。要取用:

ts
table.getAIColumnValues();   // { '0': '积极反馈…', '2': '中性反馈…' }

Options

参数类型默认说明
columnVirtTableColumn必填。要插入/接管的列
compute(row, ctx) => Promise<string> | string | AsyncIterable<string>必填。算一格的值
concurrencynumber4同时在途的最大请求数
visibleOnlybooleantrue只算渲染窗口内的行。设 false 则把当前视图全部排队 —— 只在数据量小、或确实要全量生成时用
autobooleantrue是否自动开算。设 false 则只有手动 computeAIColumn() 才动 —— 一格一次模型调用,有些场景不该在用户只是滚过去时就花钱
abortOnHidebooleantrue行滚出视口时中止在途请求
retryablebooleantrue失败的格子可点击重试
onCellChange(rowKey, state) => void单格状态变化

compute 的第二个参数:{ signal, rowKey, rowIndex, columns }

方法

方法说明
computeAIColumn(rowKeys?)手动开算;不传就是当前渲染窗口内的行
recomputeAIColumn(rowKey)忽略缓存强制重算一格
clearAIColumn(rowKey?)清缓存;不传则清全部并中止在途
getAIColumnState(rowKey){ status: 'pending' | 'computing' | 'done' | 'error', text, error }
getAIColumnValues()已算出的值(只含成功的格子)
getAIColumnStats(){ done, error, inflight, queued }

几个刻意的行为

  • 失败的格子不会被自动重排。 否则每次滚过去都重试一遍,等于无限重试烧钱。要重试就点它(或 recomputeAIColumn)。
  • abort 的行不算失败,状态直接丢弃,等下次进屏重新排队。
  • 滚回已算过的行走缓存,不重复调用。
  • 数据换了(排序/筛选/新数据)不清缓存 —— 值是按 rowKey 存的,那些行只是暂时不在视图里。

示例

微应用尚未挂载。

关于这个 demo

compute本地模板 + 假延迟,不是真在调模型 —— 文档站不该要求访客准备 API key。

重点看工具栏上那个计数器:表里 5 万行,但「已调用」永远只随你滚过的屏数增长, 而不是 5 万。快速滚动时能看到「已取消」跟着涨 —— 那是滚走的行把在途请求 abort 掉了。

源码

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

/**
 * vtAIColumn:一列的值由模型从同行其他列算出来,边算边写。
 *
 * ⚠️ 这个 demo 的 `compute` 是**本地模板 + 假延迟**,不是真在调模型 ——
 * 文档站不该要求访客准备 API key。它演示的是调度:**只为屏幕上看得见的行付钱**。
 *
 * 右上角那个计数器是重点:表里 **5 万行**,但「已调用」永远只随你滚过的行增长。
 * 全量跑一遍是 5 万次模型调用,虚拟化让它变成「二十来次 × 你实际看过的屏数」。
 * 不虚拟化的表格做不到这个优化 —— 它们的 DOM 里所有行都在,没有「可见」可用。
 *
 * 接真实模型(流式):
 *
 * ```ts
 * vtAIColumn({
 *   column: { key: 'summary', title: 'AI 摘要', width: 280 },
 *   async *compute(row, { signal }) {
 *     const res = await fetch('/api/summarize', {
 *       method: 'POST',
 *       body: JSON.stringify({ text: row.content }),
 *       signal,
 *     });
 *     const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
 *     for (;;) {
 *       const { value, done } = await reader.read();
 *       if (done) break;
 *       yield value;          // 逐段写进单元格
 *     }
 *   },
 * })
 * ```
 */

const TONE = ['积极', '中性', '偏负面'];
const THEME = ['物流时效', '客服响应', '包装完好度', '价格敏感', '功能诉求', '退换货流程'];
const ACTION = ['建议跟进', '可归档', '需人工介入', '转产品需求'];

/** 假装是模型:按行内容拼一句「摘要」,逐字吐出来 */
async function* fakeSummarize(row: Record<string, any>, signal: AbortSignal) {
  // 真实模型的首字延迟
  await sleep(180 + Math.random() * 420, signal);

  const tone = TONE[row.id % TONE.length];
  const theme = THEME[row.id % THEME.length];
  const action = ACTION[row.id % ACTION.length];
  const text = `${tone}反馈,聚焦${theme};${action}。`;

  // 逐字吐,模拟 token 流
  for (const ch of text) {
    await sleep(18 + Math.random() * 30, signal);
    if (signal.aborted) return;
    yield ch;
  }
}

function sleep(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const t = setTimeout(resolve, ms);
    signal.addEventListener('abort', () => {
      clearTimeout(t);
      const e = new Error('aborted');
      e.name = 'AbortError';
      reject(e);
    });
  });
}

export function bootstrapTableAiColumn(root: HTMLElement): () => void {
  const rowCount = 50000;

  faker.seed(20260812);
  const list = Array.from({ length: rowCount }, (_, i) => ({
    id: i,
    ticket: `FB-${String(100000 + i)}`,
    customer: faker.person.fullName(),
    channel: faker.helpers.arrayElement(['App', '网页', '电话', '邮件']),
    content: faker.lorem.sentence({ min: 8, max: 18 }),
    createdAt: faker.date.between({ from: '2026-01-01', to: '2026-08-01' }).toISOString().slice(0, 10),
  }));

  root.innerHTML = `
    <div class="demo-toolbar" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px;">
      <button type="button" id="btnComputeAll" 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="btnClear" 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="aiStat" style="font-size:12px;opacity:.75;margin-left:4px;font-variant-numeric:tabular-nums;"></span>
    </div>
    <div style="font-size:12px;opacity:.6;margin-bottom:8px;">
      表里 <strong>5 万行</strong>,但只有滚到的行才会触发生成 —— 看右边计数器:<strong>已调用</strong>
      永远只随你滚过的屏数增长,而不是 5 万。滚走时未完成的请求会被 abort(<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('#aiStat') as HTMLElement;

  let calls = 0;
  let cancelled = 0;

  const renderStat = () => {
    if (!table) return; // 见下:首帧回调可能早于实例赋值
    const s = table.getAIColumnStats();
    statEl.innerHTML =
      `共 <strong>${rowCount.toLocaleString()}</strong> 行 · ` +
      `已调用 <strong>${calls}</strong> 次 · ` +
      `已完成 ${s.done} · 在途 ${s.inflight} · 排队 ${s.queued} · 已取消 ${cancelled}`;
  };

  // 刻意用 let + 后赋值:插件的回调可能在 `new VirtTable()` 返回前就触发
  // (首帧 onRendered 在构造函数内派发),const 绑定那时还在 TDZ 里。
  // 插件侧已经用 onMounted 闸门挡住了自动计算,这里再兜一层,免得手动调用踩到。
  let table: VirtTable<any>;
  table = new VirtTable(container, {
    list,
    columns: [
      { key: 'idx', title: '#', width: 64, type: 'index' },
      { key: 'ticket', title: '工单号', width: 120 },
      { key: 'customer', title: '客户', width: 130 },
      { key: 'channel', title: '渠道', width: 90 },
      { key: 'content', title: '反馈内容', width: 300, textOverflow: 'tooltip' },
      { key: 'createdAt', title: '提交日期', width: 120 },
    ],
    itemKey: 'id',
    estimatedSize: 40,
    buffer: 4,
    border: true,
    plugins: [
      vtAIColumn({
        column: { key: 'aiSummary', title: 'AI 摘要(流式生成)', width: 280 },
        concurrency: 4,
        compute(row, { signal }) {
          calls++;
          signal.addEventListener('abort', () => {
            cancelled++;
          });
          return fakeSummarize(row, signal);
        },
        onCellChange: () => renderStat(),
      }),
    ],
  });

  renderStat();

  root.querySelector('#btnComputeAll')!.addEventListener('click', () => {
    // 当前屏每格强制重算
    for (const key of Object.keys(table.getAIColumnValues())) table.recomputeAIColumn(key);
    table.computeAIColumn();
    renderStat();
  });

  root.querySelector('#btnClear')!.addEventListener('click', () => {
    table.clearAIColumn();
    calls = 0;
    cancelled = 0;
    renderStat();
  });

  return () => {
    table.destroy();
    root.innerHTML = '';
  };
}