Skip to content

React AI 列

vtAIColumn:一列的值由模型从同行其他列算出来,边算边写。重点是调度——只为屏幕上看得见的行付钱。

示例里表有 5 万行,但「已调用」永远只随你滚过的屏数增长,而不是 5 万。滚走时未完成的请求会被 abort。不虚拟化的表格做不到这个优化:它们的 DOM 里所有行都在,没有「可见」可用。

demo 里没有真的在调模型

示例的 compute 是本地模板 + 假延迟,演示的是调度与流式写入,不是模型能力。

使用的 API

tsx
import { VirtTableReact, vtAIColumn } from '@virt-table/react';

const plugins = React.useMemo(
  () => [
    vtAIColumn({
      column: { key: 'aiSummary', title: 'AI 摘要(流式生成)', width: 280 },
      concurrency: 4,
      // async generator:逐段 yield 就是逐段写进单元格
      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;
        }
      },
      onCellChange: () => renderStat(),
    }),
  ],
  [renderStat],
);

// 实例方法(ref 上调用)
tableRef.current?.computeAIColumn();          // 为当前渲染窗口内的行计算
tableRef.current?.recomputeAIColumn(rowKey);  // 忽略缓存强制重算一格
tableRef.current?.clearAIColumn();            // 清缓存并中止在途请求
tableRef.current?.getAIColumnStats();         // { done, error, inflight, queued }

计数用 ref 而不是 state

compute 在建表时被插件捕获,示例里的「已调用 / 已取消」计数走 useRef——用 state 会拿到建表那一刻的闭包值。

完整语义(并发与队列、缓存键、abort 时机、错误态展示)见 Vanilla · AI 列

示例

微应用尚未挂载。

源码

点击查看源码
tsx
import React from 'react';
import {
  VirtTableReact,
  type ReactTableColumn,
  type VirtTableRef,
  vtAIColumn,
} from '@virt-table/react';
import { faker } from '@faker-js/faker';

/**
 * vtAIColumn:一列的值由模型从同行其他列算出来,边算边写。
 *
 * ⚠️ 这个 demo 的 `compute` 是**本地模板 + 假延迟**,不是真在调模型 ——
 * 文档站不该要求访客准备 API key。它演示的是调度:**只为屏幕上看得见的行付钱**。
 *
 * 计数器是重点:表里 **5 万行**,但「已调用」永远只随你滚过的行增长。
 * 全量跑一遍是 5 万次模型调用,虚拟化让它变成「二十来次 × 你实际看过的屏数」。
 * 不虚拟化的表格做不到这个优化 —— 它们的 DOM 里所有行都在,没有「可见」可用。
 *
 * 接真实模型时把 `compute` 换成流式请求(async generator,逐段 yield)即可。
 */
const TONE = ['积极', '中性', '偏负面'];
const THEME = ['物流时效', '客服响应', '包装完好度', '价格敏感', '功能诉求', '退换货流程'];
const ACTION = ['建议跟进', '可归档', '需人工介入', '转产品需求'];

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);
    });
  });
}

/** 假装是模型:按行内容拼一句「摘要」,逐字吐出来 */
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;
  }
}

const ROW_COUNT = 50000;

const columns: ReactTableColumn[] = [
  { 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 },
];

// 懒生成 5 万行:App 会静态 import 全部示例模块
let _db: Record<string, unknown>[] | null = null;
const getList = (): Record<string, unknown>[] => {
  if (_db) return _db;
  faker.seed(20260812);
  _db = Array.from({ length: ROW_COUNT }, (_, 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),
  }));
  return _db;
};

export default function AIColumnTable() {
  const tableRef = React.useRef<VirtTableRef>(null);
  const [stat, setStat] = React.useState('');
  const callsRef = React.useRef(0);
  const cancelledRef = React.useRef(0);
  const list = React.useMemo(getList, []);

  const renderStat = React.useCallback(() => {
    const s = tableRef.current?.getAIColumnStats();
    if (!s) return;
    setStat(
      `共 ${ROW_COUNT.toLocaleString()} 行 · 已调用 ${callsRef.current} 次 · ` +
        `已完成 ${s.done} · 在途 ${s.inflight} · 排队 ${s.queued} · 已取消 ${cancelledRef.current}`,
    );
  }, []);

  const plugins = React.useMemo(
    () => [
      vtAIColumn({
        column: { key: 'aiSummary', title: 'AI 摘要(流式生成)', width: 280 },
        concurrency: 4,
        compute(row: Record<string, any>, { signal }: { signal: AbortSignal }) {
          callsRef.current++;
          signal.addEventListener('abort', () => {
            cancelledRef.current++;
          });
          return fakeSummarize(row, signal);
        },
        onCellChange: () => renderStat(),
      }),
    ],
    [renderStat],
  );

  React.useEffect(renderStat, [renderStat]);

  return (
    <div className="demo-wrapper">
      <h3 className="demo-title">React AI 列</h3>
      <div className="virt-table-controls">
        <button
          type="button"
          className="virt-table-btn virt-table-btn-primary"
          onClick={() => {
            // 当前屏每格强制重算
            const values = tableRef.current?.getAIColumnValues() ?? {};
            for (const key of Object.keys(values)) tableRef.current?.recomputeAIColumn(key);
            tableRef.current?.computeAIColumn();
            renderStat();
          }}
        >
          为当前屏重算
        </button>
        <button
          type="button"
          className="virt-table-btn"
          onClick={() => {
            tableRef.current?.clearAIColumn();
            callsRef.current = 0;
            cancelledRef.current = 0;
            renderStat();
          }}
        >
          清空已生成
        </button>
        <span className="demo-note" style={{ fontVariantNumeric: 'tabular-nums' }}>
          {stat}
        </span>
      </div>
      <div className="demo-hint">
        表里 <strong>5 万行</strong>,但只有滚到的行才会触发生成 —— 看上面的计数器:
        <strong>已调用</strong> 永远只随你滚过的屏数增长,而不是 5 万。滚走时未完成的请求会被
        abort(<strong>已取消</strong>)。
      </div>
      <div style={{ width: '100%', height: 520 }} className="demo-container">
        <VirtTableReact
          ref={tableRef}
          columns={columns}
          options={{ list, itemKey: 'id', estimatedSize: 40, buffer: 4, border: true, plugins }}
        />
      </div>
    </div>
  );
}