Skip to content

vtAIQuery 自然语言查询

一句话描述你要看的数据,表格自己完成筛选、排序与列显隐。

插件不发网络请求,也不认识任何模型厂商。 它做两端 —— 把「表格能被怎么查」描述成 schema 交给你,再把模型吐回来的 JSON 校验、纠错、经 setState() 一次性落地;中间那步 调模型由你在 resolve 回调里实现。这样 key 不落前端,企业自研网关、服务端代理、 本地模型都能接。

快速上手

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

const table = new VirtTable(el, {
  columns,
  list,
  plugins: [
    vtAIQuery({
      async resolve({ text, prompt, jsonSchema, signal }) {
        const r = await fetch('/api/table-query', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ text, prompt, jsonSchema }),
          signal,
        });
        if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
        return r.json();
      },
    }),
  ],
});

Cmd/Ctrl + K 唤出输入条;也可以纯命令式:await table.askAITable('华东区销售额前十的客户')

服务端怎么写

resolve 拿到的四样东西正好对应一次 tool calling:

字段用途
text用户说的话
prompt拼好的上下文(列名对照、可用算子、枚举候选、今天的日期),作 system message
jsonSchema作 tool / function calling 的 input_schema,已用 enum 收窄列 key 与算子
signal关闭输入条或发起新一轮时会 abort,透传给 fetch

把模型返回的参数对象原样回给前端即可。想在服务端就先校验一遍,引纯逻辑入口:

ts
import { validateAIQuery } from '@virt-table/vanilla/ai-query';

const { query, issues, actionable } = validateAIQuery(modelOutput, columns);

@virt-table/vanilla/ai-query 不含任何 CSS 与 DOM 依赖,可以在 Node 里直接跑。

为什么必须有校验层

核心的筛选求值是宽容的 —— 未知列、不完整的条件一律视为「通过」,这样 UI 上编辑到 一半的条件不会把表瞬间筛空。但模型幻觉出一个列名时,同样的宽容会变成「筛了却没效果」, 用户完全无从判断。所以插件在应用前把问题挑出来,反馈区可以展开看:

模型常犯的偏差校验层的处理
幻觉出不存在的列丢弃该条件,报 error;其余条件照常应用
用了该列不支持的算子丢弃并报 erroreq 在只留 in/notIn 的列上会被救成 in
枚举列填了 label('华东区'按候选项回译成 value('east'),报 warn
数字写成字符串('1,000,000'转成数字(容忍千分位);转不动则丢弃并报 error
日期写成无法解析的说法丢弃并报 error
between 只给一端当作单边界,正常应用
条件树自引用 / 嵌套过深超过 10 层截断,不打穿调用栈

columns 全部无效时整体忽略 —— 否则会把所有可隐藏列一并藏掉,等于清空表格。

Options

参数类型说明
resolve(input) => Promise<unknown>必填。把自然语言交给你的模型服务
uiboolean是否挂输入条,默认 true;关掉则只能用 askAITable()
hotkeystring | null唤出快捷键,默认 'mod+k'mod = Cmd/Ctrl),null 关闭
schema{ now?: Date; maxEnumValues?: number }注入「今天」让相对时间可测;限制枚举候选进 prompt 的条数(默认 50)
confirm(result) => boolean | Promise<boolean>应用前确认,返回 false 则不应用(可先给用户看 explanation
onResult(result) => void每次解析完成后触发,无论是否应用
onError(err) => voidresolve 抛错时触发(abort 不触发)

方法

方法说明
askAITable(text)走完整链路,返回 Promise<AIQueryResult>
applyAIQuery(query)跳过模型直接应用一个查询,同样走校验
undoAIQuery()撤销上一次应用,恢复到应用前的视图状态(含列宽与列序)
getAITableSchema()当前表格对模型的结构化描述
getAIPromptContext()拼好的 prompt 上下文
getAIToolSchema()可直接用作 input_schema 的 JSON Schema
openAIQueryBar() / closeAIQueryBar() / toggleAIQueryBar()输入条开关;关闭会作废在途请求

模型该产出什么

ts
interface AIQuery {
  filter?: FilterGroup | null;   // 条件树,null 表示显式清空
  sort?: { colKey: string; order: 'asc' | 'desc' }[];
  columns?: string[];            // 「只看这几列」
  search?: string;               // 关键词搜索
  explanation?: string;          // 一句中文复述,给用户确认用
}

filter 用的就是高级筛选的条件树(与 setFilterModel() 同构),所以自然语言查询和 筛选构建器 VtFilterBuilder 产出的是同一种东西 —— 用户可以说一句话生成条件,再手动改。

三点说明

  • explanation 请务必让模型填。用户需要看见「它理解成了什么」才敢信,撤销按钮就在旁边。
  • search 只写入查询状态(服务端模式下进 RemoteQuery.search),命中高亮需要另装 vtSearch
  • 撤销只保留一步快照。需要多步回退请自行在 onResult 里维护 getState() 栈。

示例

微应用尚未挂载。

关于这个 demo

resolve本地关键词规则,不是真的在调模型 —— 文档站不该要求访客准备 API key。 它故意保留了几种真实模型常犯的偏差(枚举填 label、数字写成字符串、点「毛利大于 0」 会幻觉出一个不存在的 profit 列),好让你看见校验层在做什么。接真模型只需替换 resolve 一个函数。

源码

点击查看源码
ts
import { faker } from '@faker-js/faker';
import { VirtTable } from '@virt-table/vanilla';
import { vtAIQuery, vtColumnFilter, type AIQueryResolveInput } from '@virt-table/vanilla/plugins';

/**
 * vtAIQuery:一句话 → 筛选 / 排序 / 列显隐。
 *
 * ⚠️ 这个 demo 里的 `resolve` 是**本地关键词规则**,不是真的在调模型 ——
 * 文档站不该要求访客准备 API key,也不该把 key 放进前端。它的作用是演示
 * 「模型产出 JSON → 插件校验纠错 → setState 落地」这条链路,故意包含了几种
 * 真实模型常犯的偏差(填 label 而不是 value、数字写成字符串、幻觉列名),
 * 好让你看见校验层在干什么。
 *
 * 接真实模型时把 `resolve` 换成一次请求就行,插件其余部分不用动:
 *
 * ```ts
 * vtAIQuery({
 *   async resolve({ text, prompt, jsonSchema, signal }) {
 *     const r = await fetch('/api/table-query', {
 *       method: 'POST',
 *       headers: { 'content-type': 'application/json' },
 *       body: JSON.stringify({ text, prompt, jsonSchema }),
 *       signal,
 *     });
 *     if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
 *     return r.json();
 *   },
 * })
 * ```
 *
 * 服务端那一侧:把 `prompt` 作为 system message,`jsonSchema` 作为 tool /
 * function calling 的 `input_schema`,把模型返回的参数对象原样回给前端。
 * 想在服务端就先校验一遍,引 `@virt-table/vanilla/ai-query` 的
 * `validateAIQuery()` —— 那个入口是纯逻辑,不含任何 CSS 与 DOM 依赖。
 */

const REGIONS = [
  { label: '华东区', value: 'east' },
  { label: '华南区', value: 'south' },
  { label: '华北区', value: 'north' },
  { label: '西南区', value: 'west' },
];

const INDUSTRIES = [
  { label: '制造业', value: 'manufacturing' },
  { label: '零售', value: 'retail' },
  { label: '金融', value: 'finance' },
  { label: '医疗', value: 'health' },
];

/** 中文数量词 → 数字,让「一百万」这类说法也能被规则识别 */
function parseAmount(text: string): number | null {
  const cn = text.match(/([\d.]+)\s*(万|百万|千万|亿)/);
  if (cn) {
    const n = Number(cn[1]);
    const unit = { 万: 1e4, 百万: 1e6, 千万: 1e7, 亿: 1e8 }[cn[2]]!;
    return n * unit;
  }
  const plain = text.match(/([\d,]+)/);
  return plain ? Number(plain[1].replace(/,/g, '')) : null;
}

/** 可被「只看这几列 / 重置」摆布的数据列(功能列 idx 不在其列) */
const ALL_COLUMN_KEYS = ['customer', 'region', 'industry', 'amount', 'signedAt', 'status'];

/**
 * 假装是模型:认几种常见句式,产出 AIQuery 形状的 JSON。
 *
 * 刻意保留的「模型口音」——校验层会把它们一一纠正,你能在反馈区看到:
 *   · 大区填 label(`'华东区'`)而不是候选值(`'east'`)
 *   · 金额填字符串(`'1000000'`)而不是数字
 *   · 提到「毛利」时产出一个并不存在的 `profit` 列
 */
function fakeModel(text: string): Record<string, any> {
  const conditions: any[] = [];
  const said: string[] = [];

  for (const r of REGIONS) {
    if (text.includes(r.label) || text.includes(r.label.replace(/$/, ''))) {
      // 口音①:填的是用户说的词,不是候选值
      conditions.push({ kind: 'condition', colKey: 'region', operator: 'in', value: [r.label] });
      said.push(r.label);
    }
  }

  for (const ind of INDUSTRIES) {
    if (text.includes(ind.label)) {
      conditions.push({ kind: 'condition', colKey: 'industry', operator: 'in', value: [ind.value] });
      said.push(ind.label);
    }
  }

  const amount = parseAmount(text);
  if (amount !== null && /大于|超过|高于|以上|多于|>/.test(text)) {
    // 口音②:数字写成了字符串
    conditions.push({ kind: 'condition', colKey: 'amount', operator: 'gt', value: String(amount) });
    said.push(`销售额 > ${amount.toLocaleString()}`);
  } else if (amount !== null && /小于|低于|不到|以下|少于|</.test(text)) {
    conditions.push({ kind: 'condition', colKey: 'amount', operator: 'lt', value: String(amount) });
    said.push(`销售额 < ${amount.toLocaleString()}`);
  }

  if (/毛利/.test(text)) {
    // 口音③:幻觉出一个数据里没有的列
    conditions.push({ kind: 'condition', colKey: 'profit', operator: 'gt', value: 0 });
  }

  if (/未签约|没签约|待跟进/.test(text)) {
    conditions.push({ kind: 'condition', colKey: 'status', operator: 'in', value: ['pending'] });
    said.push('未签约');
  }

  const sort: any[] = [];
  if (/销售额|金额/.test(text) && /降序|从高到低|最高|倒序/.test(text)) {
    sort.push({ colKey: 'amount', order: 'desc' });
    said.push('按销售额降序');
  } else if (/销售额|金额/.test(text) && /升序|从低到高|最低/.test(text)) {
    sort.push({ colKey: 'amount', order: 'asc' });
    said.push('按销售额升序');
  }
  if (/最近|最新|日期.*降序/.test(text)) {
    sort.push({ colKey: 'signedAt', order: 'desc' });
    said.push('按签约日期从近到远');
  }

  const columns: string[] = [];
  if (/只看|只显示|只要/.test(text)) {
    if (/名称|客户/.test(text)) columns.push('customer');
    if (/大区|区域/.test(text)) columns.push('region');
    if (/销售额|金额/.test(text)) columns.push('amount');
    if (/日期|时间/.test(text)) columns.push('signedAt');
    if (/行业/.test(text)) columns.push('industry');
    if (columns.length > 0) said.push(`只看 ${columns.length} 列`);
  }

  const out: Record<string, any> = {
    explanation: said.length > 0 ? said.join('、') : '没有识别出条件(本地规则的能力有限,换真模型会好很多)',
  };
  if (conditions.length > 0) out.filter = { kind: 'group', logic: 'and', children: conditions };
  if (sort.length > 0) out.sort = sort;
  if (columns.length > 0) out.columns = columns;
  // 「重置」要把三样一起还原 —— 只清筛选的话,之前「只看两列」留下的列显隐还在,
  // 用户会觉得没清干净。三个字段各自的「空值」语义:null / [] / 全部列
  if (/重置|全部显示|恢复默认/.test(text)) {
    out.filter = null;
    out.sort = [];
    out.columns = ALL_COLUMN_KEYS.slice();
    out.explanation = '已重置筛选、排序与列显隐';
  } else if (/清空|取消筛选/.test(text)) {
    out.filter = null;
    out.explanation = '已清空筛选条件';
  }
  return out;
}

const PRESETS = [
  '华东区销售额超过 500 万的客户',
  '金融行业的,按销售额从高到低',
  '只看客户名称和销售额',
  '西南区未签约的,按签约日期最近排',
  '毛利大于 0 的',
  '重置全部',
];

export function bootstrapTableAIQuery(root: HTMLElement): () => void {
  const rowCount = 100000;

  const columns = [
    { key: 'idx', title: '#', width: 64, type: 'index' as const },
    { key: 'customer', title: '客户名称', width: 200 },
    {
      key: 'region',
      title: '大区',
      width: 110,
      sortable: true,
      filterType: 'enum' as const,
      filters: REGIONS.map((r) => ({ label: r.label, value: r.value })),
    },
    {
      key: 'industry',
      title: '行业',
      width: 110,
      filterType: 'enum' as const,
      filters: INDUSTRIES.map((i) => ({ label: i.label, value: i.value })),
    },
    { key: 'amount', title: '销售额', width: 140, sortable: true, filterType: 'number-range' as const },
    { key: 'signedAt', title: '签约日期', width: 130, sortable: true, filterType: 'date-range' as const },
    {
      key: 'status',
      title: '状态',
      width: 100,
      filterType: 'enum' as const,
      filters: [
        { label: '已签约', value: 'signed' },
        { label: '未签约', value: 'pending' },
      ],
    },
  ];

  faker.seed(20260812);
  const list = Array.from({ length: rowCount }, (_, i) => ({
    id: i,
    customer: faker.company.name(),
    region: faker.helpers.arrayElement(REGIONS).value,
    industry: faker.helpers.arrayElement(INDUSTRIES).value,
    amount: faker.number.int({ min: 10_000, max: 20_000_000 }),
    signedAt: faker.date.between({ from: '2024-01-01', to: '2026-08-01' }).toISOString().slice(0, 10),
    status: faker.helpers.arrayElement(['signed', 'pending']),
  }));

  root.innerHTML = `
    <div class="demo-toolbar" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px;">
      <span style="font-size:13px;opacity:.7;">试试:</span>
      ${PRESETS.map(
        (p, i) =>
          `<button type="button" data-preset="${i}" style="cursor:pointer;font-size:12px;padding:3px 10px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">${p}</button>`,
      ).join('')}
    </div>
    <div style="font-size:12px;opacity:.6;margin-bottom:8px;">
      Cmd/Ctrl + K 唤出输入条。本 demo 用本地关键词规则模拟模型输出(含几种常见偏差),演示的是校验与落地链路。
    </div>
    <div style="width:100%;height:560px;" class="demo-container" id="tableContainer"></div>
  `;
  const container = root.querySelector('#tableContainer') as HTMLElement;

  const table = new VirtTable(container, {
    list,
    columns,
    itemKey: 'id',
    estimatedSize: 40,
    buffer: 6,
    border: true,
    plugins: [
      vtColumnFilter(),
      vtAIQuery({
        async resolve({ text }: AIQueryResolveInput) {
          // 真实链路是一次网络往返,这里也给点延迟,好看到「正在理解…」的状态
          await new Promise((r) => setTimeout(r, 240));
          return fakeModel(text);
        },
      }),
    ],
  });

  table.openAIQueryBar();

  const onPresetClick = (e: Event) => {
    const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-preset]');
    if (!btn) return;
    const text = PRESETS[Number(btn.dataset.preset)];
    const input = root.querySelector<HTMLInputElement>('.vt-ai-input');
    table.openAIQueryBar();
    if (input) input.value = text;
    table.askAITable(text).catch(() => {});
  };
  root.addEventListener('click', onPresetClick);

  return () => {
    root.removeEventListener('click', onPresetClick);
    table.destroy();
    root.innerHTML = '';
  };
}