shadimage
data-table-filters
blockdata-table-filter-command-aisource: ok · hit

Data Table AI Filter Command Palette

AI-powered command palette that translates natural language queries into structured table filters. Provider-agnostic — works with any LLM via Vercel AI SDK.

npx shadcn add "https://data-table.openstatus.dev/r/data-table-filter-command-ai.json"
free

Where it goes

data-table

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ai@^6.0.137@ai-sdk/react@^3.0.139zod@^4.3.6date-fns@^4.1.0lucide-react@^0.469.0sonner@^1.7.2

Registry dependencies

https://data-table.openstatus.dev/r/data-table.jsonhttps://data-table.openstatus.dev/r/data-table-filter-command.jsonhttps://data-table.openstatus.dev/r/data-table-schema.jsonbuttoncommandkbdseparator

Files11 · source cached 10/15/2026, 11:40:31 AM

src/components/data-table/data-table-filter-command-ai/index.tsx
"use client";

import { TextShimmer } from "@/components/data-table/data-table-filter-command-ai/text-shimmer";
import { useAIFilters } from "@/components/data-table/data-table-filter-command-ai/use-ai-filters";
import {
  columnFiltersParserFromSchema,
  getFieldOptions,
  getFilterValue,
  getWordByCaretPosition,
  replaceInputByFieldType,
} from "@/components/data-table/data-table-filter-command/utils";
import { useDataTable } from "@/components/data-table/data-table-provider";
import type { DataTableFilterField } from "@/components/data-table/types";
import { buttonVariants } from "@/components/ui/button";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command";
import { Kbd } from "@/components/ui/kbd";
import { Separator } from "@/components/ui/separator";
import { useHotKey } from "@/hooks/use-hot-key";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { isStructuredQuery } from "@/lib/ai";
import { getCommandHistoryKey } from "@/lib/constants/local-storage";
import { formatCompactNumber } from "@/lib/format";
import type { SchemaDefinition } from "@/lib/store/schema/types";
import { boxRadiusClassName } from "@/lib/style";
import type { TableSchemaDefinition } from "@/lib/table-schema";
import { cn } from "@/lib/utils";
import { Command as CommandPrimitive } from "cmdk";
import { formatDistanceToNow } from "date-fns";
import { LoaderCircle, Search, Sparkles, X } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";

interface DataTableFilterAICommandProps {
  /** BYOS schema definition for parsing/serializing filter values */
  schema: SchemaDefinition;
  /** Table schema definition for AI context generation */
  tableSchema: TableSchemaDefinition;
  /** API endpoint that streams AI filter results */
  api: string;
  /** Unique ID for this table (used to namespace localStorage) */
  tableId?: string;
}

/**
 * Box chrome — radius, border, background, focus ring — comes from the
 * project's own `buttonVariants` rather than from hardcoded utilities: every
 * shadcn style keeps its box rhythm in that string, so a `rounded-lg border`
 * that looks right on the default style is wrong on `nova`, which rounds and
 * pads its boxes with other tokens. The closed trigger and the open command
 * box share the constant, which is what keeps the two states identical.
 */
const boxClassName = cn(
  buttonVariants({ variant: "outline" }),
  "h-11 w-full justify-start gap-2 px-3 text-sm font-normal",
);

export function DataTableFilterAICommand({
  schema,
  tableSchema,
  api,
  tableId = "default",
}: DataTableFilterAICommandProps) {
  const {
    table,
    isLoading,
    filterFields: _filterFields,
    getFacetedUniqueValues,
  } = useDataTable();
  const columnFilters = table.state.columnFilters;
  const inputRef = useRef<HTMLInputElement>(null);
  const [open, setOpen] = useState<boolean>(false);
  const [currentWord, setCurrentWord] = useState<string>("");
  const isSerializingRef = useRef(false);
  const filterFields = useMemo(
    () => _filterFields?.filter((i) => !i.commandDisabled),
    [_filterFields],
  );
  const columnParser = useMemo(
    () => columnFiltersParserFromSchema({ schema, filterFields }),
    [schema, filterFields],
  );
  const [inputValue, setInputValue] = useState<string>(
    columnParser.serialize(columnFilters),
  );
  const [lastSearches, setLastSearches] = useLocalStorage<
    {
      search: string;
      timestamp: number;
    }[]
  >(getCommandHistoryKey(tableId), []);

  // Detect if the current input looks like natural language (for UI hints).
  // Single-word input that partially matches a field name → structured mode.
  // Multi-word input without key:value syntax → natural language mode.
  const isNaturalLanguage = useMemo(() => {
    const trimmed = inputValue.trim();
    if (!trimmed) return false;
    if (isStructuredQuery(trimmed, tableSchema)) return false;

    const words = trimmed.split(/\s+/);

    // Single word: only treat as natural language if it doesn't match any field
    if (words.length === 1) {
      const word = words[0]!.toLowerCase();
      const matchesField = filterFields.some((f) => {
        const key = String(f.value).toLowerCase();
        return key.startsWith(word) || key.includes(word);
      });
      if (matchesField) return false;
    }

    // Multiple words without key:value → natural language
    return (
      words.length > 1 ||
      !filterFields.some((f) => {
        const key = String(f.value).toLowerCase();
        return key === words[0]?.toLowerCase();
      })
    );
  }, [inputValue, tableSchema, filterFields]);

  // Track the last AI query for display while loading
  const [aiQuery, setAIQuery] = useState<string | null>(null);

  // AI filters hook
  const { infer, isLoading: isAILoading } = useAIFilters({
    api,
    tableSchema,
    onField(key, value) {
      table.getColumn(key)?.setFilterValue(value);
    },
    onFinish(state) {
      // Final reconciliation: set all validated fields
      for (const [key, value] of Object.entries(state)) {
        table.getColumn(key)?.setFilterValue(value);
      }
      // Serialize from the validated state directly to avoid stale table state
      const syntheticFilters = Object.entries(state).map(([id, value]) => ({
        id,
        value,
      }));
      isSerializingRef.current = true;
      setInputValue(columnParser.serialize(syntheticFilters));
    },
    onComplete() {
      setAIQuery(null);
    },
    onStart() {
      // Clear existing filters before AI applies new ones (replace-all strategy)
      for (const field of filterFields) {
        if (typeof field.value === "string") {
          table.getColumn(field.value)?.setFilterValue(undefined);
        }
      }
    },
    onError(error) {
      setAIQuery(null);
      toast.error("Failed to infer filters", {
        description: error.message,
      });
    },
  });

  useEffect(() => {
    if (isSerializingRef.current) {
      isSerializingRef.current = false;
      return;
    }
    if (currentWord !== "" && open) return;
    if (currentWord !== "" && !open) setCurrentWord("");
    if (inputValue.trim() === "" && !open) return;

    const searchParams = columnParser.parse(inputValue);

    const currentFilters = table.state.columnFilters;
    const currentEnabledFilters = currentFilters.filter((filter) => {
      const field = _filterFields?.find((field) => field.value === filter.id);
      return !field?.commandDisabled;
    });
    for (const key of Object.keys(searchParams)) {
      const value = searchParams[key as keyof typeof searchParams];
      table.getColumn(key)?.setFilterValue(value);
    }
    const currentFiltersToReset = currentEnabledFilters.filter((filter) => {
      return !(filter.id in searchParams);
    });
    for (const filter of currentFiltersToReset) {
      table.getColumn(filter.id)?.setFilterValue(undefined);
    }

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [inputValue, open, currentWord]);

  useEffect(() => {
    if (!open) {
      isSerializingRef.current = true;
      setInputValue(columnParser.serialize(columnFilters));
    }
  }, [columnFilters, filterFields, open]);

  useHotKey(() => setOpen((open) => !open), "k");

  useEffect(() => {
    if (open) {
      inputRef?.current?.focus();
    }
  }, [open]);

  function handleClose() {
    setOpen(false);
    const search = inputValue.trim();
    if (!search) return;
    const timestamp = Date.now();
    const searchIndex = lastSearches.findIndex(
      (item) => item.search === search,
    );
    if (searchIndex !== -1) {
      setLastSearches(
        lastSearches.map((item, i) =>
          i === searchIndex ? { ...item, timestamp } : item,
        ),
      );
      return;
    }
    setLastSearches([...lastSearches, { search, timestamp }]);
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key === "Escape") {
      inputRef?.current?.blur();
      return;
    }
    if (e.key === "Enter" && isNaturalLanguage) {
      // Let cmdk handle Enter if an item is selected (e.g., a suggestion)
      const selected = document.querySelector("[cmdk-item][data-selected]");
      if (selected) return;

      e.preventDefault();
      const query = inputValue.trim();
      const handled = infer(inputValue);
      if (handled) {
        setAIQuery(query);
        handleClose();
      }
    }
  }

  return (
    <div>
      {/* REMINDER: the closed trigger and the open command box draw the same
          `boxClassName`, so toggling the command can never change the box's
          height, radius or padding, and the dropdown borrows its radius from
          the same place. Keep the height on the boxes, not on their children —
          a taller child (kbd, spinner, browser extension node) must not grow
          one state only. */}
      <button
        type="button"
        className={cn(
          boxClassName,
          "text-muted-foreground",
          open ? "hidden" : "visible",
        )}
        onClick={() => setOpen(true)}
      >
        {isLoading || isAILoading ? (
          <LoaderCircle className="size-4 shrink-0 animate-spin opacity-50" />
        ) : (
          <Search className="size-4 shrink-0 opacity-50" />
        )}
        <span className="w-full max-w-sm truncate text-left md:max-w-xl lg:max-w-4xl xl:max-w-5xl">
          {aiQuery ? (
            <TextShimmer duration={2}>{aiQuery}</TextShimmer>
          ) : inputValue.trim() ? (
            <span className="text-foreground">{inputValue}</span>
          ) : (
            <span>Search data table...</span>
          )}
        </span>
        <Kbd className="text-muted-foreground ml-auto">
          <span className="mr-1">⌘</span>
          <span>K</span>
        </Kbd>
      </button>
      <Command
        className={cn(
          // The root only lays the box and the dropdown out: every visible
          // edge below is drawn by them, so strip the surface a style may put
          // here (nova ships `bg-popover p-1`, whose padding inset the
          // dropdown by 4px and whose radius disagreed with the trigger). Its
          // size is left alone: styles use `size-full`, which `h-*`/`w-*` do
          // not replace in `cn`, and a full height resolves to auto here.
          "overflow-visible border-none bg-transparent p-0 shadow-none",
          open ? "visible" : "hidden",
        )}
        filter={(value, search, keywords) =>
          getFilterValue({ value, search, keywords, currentWord })
        }
      >
        <div
          className={cn(
            boxClassName,
            "focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
          )}
        >
          <Search className="size-4 shrink-0 opacity-50" />
          <CommandPrimitive.Input
            ref={inputRef}
            value={inputValue}
            onValueChange={setInputValue}
            onKeyDown={handleKeyDown}
            onBlur={handleClose}
            onInput={(e: React.FormEvent<HTMLInputElement>) => {
              const caretPosition = e.currentTarget?.selectionStart || -1;
              const value = e.currentTarget?.value || "";
              const word = getWordByCaretPosition({ value, caretPosition });
              setCurrentWord(word);
            }}
            placeholder="Search data table..."
            className="text-foreground placeholder:text-muted-foreground flex h-full w-full bg-transparent text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
          />
        </div>
        <div className="relative">
          <div
            className={cn(
              "bg-popover text-popover-foreground animate-in absolute top-2 z-10 w-full overflow-hidden border shadow-md outline-hidden",
              boxRadiusClassName,
            )}
          >
            <CommandList className="max-h-[310px]">
              {!isNaturalLanguage && (
                <>
                  <CommandGroup heading="Filter">
                    {filterFields.map((field) => {
                      if (typeof field.value !== "string") return null;
                      if (inputValue.includes(`${field.value}:`)) return null;
                      return (
                        <CommandItem
                          key={field.value}
                          value={field.value}
                          onMouseDown={(e) => {
                            e.preventDefault();
                            e.stopPropagation();
                          }}
                          onSelect={(value) => {
                            setInputValue((prev) => {
                              if (currentWord.trim() === "") {
                                const input = `${prev}${value}`;
                                return `${input}:`;
                              }
                              const isStarting = currentWord === prev;
                              const prefix = isStarting ? "" : " ";
                              const input = prev.replace(
                                `${prefix}${currentWord}`,
                                `${prefix}${value}`,
                              );
                              return `${input}:`;
                            });
                            setCurrentWord(`${value}:`);
                          }}
                          className="group"
                        >
                          {field.value}
                          <CommandItemSuggestions field={field} />
                        </CommandItem>
                      );
                    })}
                  </CommandGroup>
                  <CommandSeparator />
                  <CommandGroup heading="Query">
                    {filterFields?.map((field) => {
                      if (typeof field.value !== "string") return null;
                      if (!currentWord.includes(`${field.value}:`)) return null;

                      const column = table.getColumn(field.value);
                      const facetedValue =
                        getFacetedUniqueValues?.(table, field.value) ||
                        column?.getFacetedUniqueValues();

                      const options = getFieldOptions({ field, facetedValue });

                      return options.map((optionValue) => {
                        return (
                          <CommandItem
                            key={`${String(field.value)}:${optionValue}`}
                            value={`${String(field.value)}:${optionValue}`}
                            onMouseDown={(e) => {
                              e.preventDefault();
                              e.stopPropagation();
                            }}
                            onSelect={(value) => {
                              setInputValue((prev) =>
                                replaceInputByFieldType({
                                  prev,
                                  currentWord,
                                  optionValue,
                                  value,
                                  field,
                                }),
                              );
                              setCurrentWord("");
                            }}
                          >
                            {`${optionValue}`}
                            {/* data-slot="command-shortcut" hides the check icon the shadcn nova
                                presets append to every item - a second ml-auto child would
                                otherwise split the row and leave the count mid-way */}
                            {facetedValue?.has(optionValue) ? (
                              <span
                                data-slot="command-shortcut"
                                className="text-muted-foreground ml-auto font-mono"
                              >
                                {formatCompactNumber(
                                  facetedValue.get(optionValue) || 0,
                                )}
                              </span>
                            ) : null}
                          </CommandItem>
                        );
                      });
                    })}
                  </CommandGroup>
                  <CommandEmpty>No results found.</CommandEmpty>
                </>
              )}
              {isNaturalLanguage && inputValue.trim() && (
                <CommandGroup heading="Infer">
                  <CommandItem
                    value={`ai:${inputValue.trim()}`}
                    onMouseDown={(e) => {
                      e.preventDefault();
                      e.stopPropagation();
                    }}
                    onSelect={() => {
                      const query = inputValue.trim();
                      const handled = infer(inputValue);
                      if (handled) {
                        setAIQuery(query);
                        handleClose();
                      }
                    }}
                  >
                    <Sparkles className="size-4 shrink-0" />
                    {inputValue.trim()}
                    <span
                      data-slot="command-shortcut"
                      className="text-muted-foreground ml-auto text-xs"
                    >
                      describe your query to infer filters
                    </span>
                  </CommandItem>
                </CommandGroup>
              )}
              {lastSearches.length > 0 && (
                <>
                  <CommandSeparator />
                  <CommandGroup heading="Suggestions">
                    {lastSearches
                      .sort((a, b) => b.timestamp - a.timestamp)
                      .slice(0, 5)
                      .map((item) => {
                        return (
                          <CommandItem
                            key={`suggestion:${item.search}`}
                            value={`suggestion:${item.search}`}
                            onMouseDown={(e) => {
                              e.preventDefault();
                              e.stopPropagation();
                            }}
                            onSelect={(value) => {
                              const search = value.replace("suggestion:", "");
                              setInputValue(`${search} `);
                              setCurrentWord("");
                            }}
                            className="group"
                          >
                            {item.search}
                            <span
                              data-slot="command-shortcut"
                              className="text-muted-foreground/80 ml-auto truncate group-aria-selected:block"
                            >
                              {formatDistanceToNow(item.timestamp, {
                                addSuffix: true,
                              })}
                            </span>
                            <button
                              type="button"
                              onMouseDown={(e) => {
                                e.preventDefault();
                                e.stopPropagation();
                              }}
                              onClick={(e) => {
                                e.preventDefault();
                                e.stopPropagation();
                                setLastSearches(
                                  lastSearches.filter(
                                    (i) => i.search !== item.search,
                                  ),
                                );
                              }}
                              className={cn(
                                "hover:bg-background ml-1 hidden p-0.5 group-aria-selected:block",
                                boxRadiusClassName,
                              )}
                            >
                              <X className="h-4 w-4" />
                            </button>
                          </CommandItem>
                        );
                      })}
                  </CommandGroup>
                </>
              )}
            </CommandList>
            <div
              className="bg-accent/50 text-accent-foreground flex flex-wrap justify-between gap-2 border-t px-2 py-1.5 text-sm"
              cmdk-footer=""
            >
              <div className="flex flex-wrap gap-2">
                <span>
                  Use <Kbd>↑</Kbd> <Kbd>↓</Kbd> to navigate
                </span>
                <span>
                  <Kbd>Enter</Kbd> to query
                </span>
                <span>
                  <Kbd>Esc</Kbd> to close
                </span>
                <Separator
                  orientation="vertical"
                  className="my-auto data-[orientation=vertical]:h-3"
                />
                <span>
                  Union: <Kbd>regions:a,b</Kbd>
                </span>
                <span>
                  Range: <Kbd>p95:59-340</Kbd>
                </span>
                <span>
                  Spaces: <Kbd>name:&quot;a b&quot;</Kbd>
                </span>
                <Separator
                  orientation="vertical"
                  className="my-auto data-[orientation=vertical]:h-3"
                />
                <span>
                  AI:{" "}
                  <Kbd>
                    <Sparkles className="size-2.5 shrink-0" />
                  </Kbd>
                </span>
              </div>
              {lastSearches.length ? (
                <button
                  type="button"
                  className="text-muted-foreground hover:text-accent-foreground"
                  onMouseDown={(e) => {
                    e.preventDefault();
                    e.stopPropagation();
                  }}
                  onClick={() => setLastSearches([])}
                >
                  Clear suggestions
                </button>
              ) : null}
            </div>
          </div>
        </div>
      </Command>
    </div>
  );
}

function CommandItemSuggestions<TData>({
  field,
}: {
  field: DataTableFilterField<TData>;
}) {
  const { table, getFacetedMinMaxValues, getFacetedUniqueValues } =
    useDataTable();
  const value = field.value as string;
  switch (field.type) {
    case "checkbox": {
      return (
        <span className="text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block">
          {getFacetedUniqueValues
            ? Array.from(getFacetedUniqueValues(table, value)?.keys() || [])
                .map((value) => `[${value}]`)
                .join(" ")
            : field.options?.map(({ value }) => `[${value}]`).join(" ")}
        </span>
      );
    }
    case "slider": {
      const [min, max] = getFacetedMinMaxValues?.(table, value) || [
        field.min,
        field.max,
      ];
      return (
        <span className="text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block">
          [{min} - {max}]
        </span>
      );
    }
    case "input": {
      return (
        <span className="text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block">
          [{`${String(field.value)}`} input]
        </span>
      );
    }
    default: {
      return null;
    }
  }
}
src/components/data-table/data-table-filter-command-ai/text-shimmer.tsx
"use client";

import { cn } from "@/lib/utils";

export type TextShimmerProps = {
  as?: React.ElementType;
  duration?: number;
  spread?: number;
  children: React.ReactNode;
} & React.HTMLAttributes<HTMLElement>;

export function TextShimmer({
  as = "span",
  className,
  duration = 4,
  spread = 20,
  children,
  ...props
}: TextShimmerProps) {
  const dynamicSpread = Math.min(Math.max(spread, 5), 45);
  const Component = as;

  return (
    <Component
      className={cn(
        "bg-size-[200%_auto] bg-clip-text font-medium text-transparent",
        "animate-[shimmer_4s_infinite_linear]",
        className,
      )}
      style={{
        backgroundImage: `linear-gradient(to right, var(--muted-foreground) ${50 - dynamicSpread}%, var(--foreground) 50%, var(--muted-foreground) ${50 + dynamicSpread}%)`,
        animationDuration: `${duration}s`,
      }}
      {...props}
    >
      {children}
    </Component>
  );
}
src/components/data-table/data-table-filter-command-ai/use-ai-filters.ts
import { experimental_useObject as useObject } from "@ai-sdk/react";
import {
  diffPartialState,
  generateAIOutputSchema,
  isStructuredQuery,
  parseAIResponse,
} from "@/lib/ai";
import type { TableSchemaDefinition } from "@/lib/table-schema";
import { useCallback, useEffect, useMemo, useRef } from "react";

export type UseAIFiltersOptions = {
  /** The API endpoint that streams AI filter results */
  api: string;
  /** Table schema definition for generating the output schema and detecting structured queries */
  tableSchema: TableSchemaDefinition;
  /** Called for each progressively completed field */
  onField: (key: string, value: unknown) => void;
  /** Called with the final validated state on stream end */
  onFinish: (state: Record<string, unknown>) => void;
  /** Called when the AI call fails */
  onError?: (error: Error) => void;
  /** Called before AI filters are applied — use to reset existing filters */
  onStart?: () => void;
  /** Called when the stream ends, regardless of validation success — use for cleanup */
  onComplete?: () => void;
};

export function useAIFilters({
  api,
  tableSchema,
  onField,
  onFinish,
  onError,
  onStart,
  onComplete,
}: UseAIFiltersOptions) {
  const prevRef = useRef<Record<string, unknown>>({});
  const outputSchema = useMemo(
    () => generateAIOutputSchema(tableSchema),
    [tableSchema],
  );

  const { submit, object, isLoading, error } = useObject({
    api,
    schema: outputSchema,
    onFinish({ object }) {
      prevRef.current = {};
      if (object) {
        const validated = parseAIResponse(
          tableSchema,
          object as Record<string, unknown>,
        );
        if (validated) {
          onFinish(validated);
        }
      }
      onComplete?.();
    },
    onError(error) {
      onComplete?.();
      onError?.(error);
    },
  });

  // Progressive application via diffPartialState
  useEffect(() => {
    if (!object) return;
    const next = object as Record<string, unknown>;
    const completed = diffPartialState(prevRef.current, next, tableSchema);
    for (const { key, value } of completed) {
      onField(key, value);
    }
    prevRef.current = { ...next };
  }, [object]); // eslint-disable-line react-hooks/exhaustive-deps

  const infer = useCallback(
    (query: string): boolean => {
      if (isStructuredQuery(query, tableSchema)) return false;
      const trimmed = query.trim();
      if (!trimmed) return false;

      onStart?.();
      prevRef.current = {};
      submit({ query: trimmed });
      return true;
    },
    [submit, tableSchema, onStart],
  );

  return { infer, isLoading, error };
}
src/lib/ai/index.ts
export {
  generateAIContext,
  type AIContext,
  type AIFieldContext,
} from "./context";
export { generateAIPrompt, type GenerateAIPromptOptions } from "./prompt";
export { generateAIOutputSchema } from "./output-schema";
export { diffPartialState, type CompletedField } from "./diff-partial";
export { parseAIResponse } from "./parse-response";
export { isStructuredQuery } from "./detect";
export {
  createAIFilterHandler,
  type AIFilterHandlerOptions,
} from "./create-ai-filter-handler";
src/lib/ai/context.ts
import {
  resolveColumn,
  type TableSchemaDefinition,
} from "@/lib/table-schema";

export type AIFieldContext = {
  key: string;
  label: string;
  description?: string;
  dataType: string;
  filterType: string;
  allowedValues?: string[];
  min?: number;
  max?: number;
  unit?: string;
};

export type AIContext = {
  fields: AIFieldContext[];
};

/**
 * Extracts structured context from a table schema definition for use in LLM prompts.
 *
 * Returns metadata about each filterable column: field name, label, data type,
 * filter type, allowed values, bounds, and description. Non-filterable columns
 * are excluded. Command-disabled columns are intentionally included so the AI
 * can set filters (e.g. date ranges) that aren't available in the command palette.
 */
export function generateAIContext(schema: TableSchemaDefinition): AIContext {
  const fields: AIFieldContext[] = [];

  for (const [key, builder] of Object.entries(schema)) {
    const config = resolveColumn(builder);
    if (!config.filter) continue;

    const field: AIFieldContext = {
      key,
      label: config.label || key,
      dataType: config.kind,
      filterType: config.filter.type,
    };

    if (config.description) {
      field.description = config.description;
    }

    // Allowed values for checkbox filters (enums, booleans, arrays)
    if (config.filter.type === "checkbox" && config.filter.options) {
      field.allowedValues = config.filter.options.map((o) => String(o.value));
    } else if (config.kind === "enum" && config.enumValues) {
      field.allowedValues = [...config.enumValues];
    } else if (
      config.kind === "array" &&
      config.arrayItem?.kind === "enum" &&
      config.arrayItem.enumValues
    ) {
      field.allowedValues = [...config.arrayItem.enumValues];
    }

    // Bounds for slider filters
    if (config.filter.type === "slider") {
      if (config.filter.min !== undefined) field.min = config.filter.min;
      if (config.filter.max !== undefined) field.max = config.filter.max;
    }

    // Unit from filter or display config
    if (config.filter.unit) {
      field.unit = config.filter.unit;
    } else if (
      config.display.type === "number" &&
      "unit" in config.display &&
      config.display.unit
    ) {
      field.unit = config.display.unit;
    }

    fields.push(field);
  }

  return { fields };
}
src/lib/ai/prompt.ts
import type { TableSchemaDefinition } from "@/lib/table-schema";
import {
  generateAIContext,
  type AIContext,
  type AIFieldContext,
} from "./context";

export type GenerateAIPromptOptions = {
  /** Current date/time for resolving relative time expressions like "last hour". */
  now?: Date;
};

function formatField(field: AIFieldContext): string {
  const parts: string[] = [];
  parts.push(`- **${field.key}** (label: "${field.label}")`);
  parts.push(`  Type: ${field.dataType}, Filter: ${field.filterType}`);

  if (field.description) {
    parts.push(`  Description: ${field.description}`);
  }
  if (field.allowedValues && field.allowedValues.length > 0) {
    parts.push(
      `  Allowed values: ${field.allowedValues.map((v) => `"${v}"`).join(", ")}`,
    );
  }
  if (field.min !== undefined || field.max !== undefined) {
    const bounds = [];
    if (field.min !== undefined) bounds.push(`min: ${field.min}`);
    if (field.max !== undefined) bounds.push(`max: ${field.max}`);
    parts.push(`  Range: ${bounds.join(", ")}`);
  }
  if (field.unit) {
    parts.push(`  Unit: ${field.unit}`);
  }

  return parts.join("\n");
}

function formatPrompt(
  context: AIContext,
  options?: GenerateAIPromptOptions,
): string {
  const lines: string[] = [];

  lines.push(
    "You are a filter assistant for a data table. Given a natural language query, " +
      "return a JSON object with the appropriate filter values. Only include fields " +
      "that the user's query references. If the query doesn't match any field, return " +
      "an empty object {}.",
  );

  lines.push("");
  lines.push("## Available filter fields");
  lines.push("");

  for (const field of context.fields) {
    lines.push(formatField(field));
    lines.push("");
  }

  lines.push("## Output rules");
  lines.push("");
  lines.push("- For **input** filters (text search): return a string value.");
  lines.push(
    "- For **checkbox** filters (multi-select): return an array of allowed values. Only use values from the allowed values list.",
  );
  lines.push(
    "- For **slider** filters (numeric range): return a [min, max] tuple within the field's bounds.",
  );
  lines.push(
    "- For **timerange** filters (date range): return a [start, end] tuple of ISO 8601 datetime strings.",
  );
  lines.push(
    "- Only include fields relevant to the query. Omit fields the user didn't mention or imply.",
  );
  lines.push("- Return valid JSON only. No explanations.");
  lines.push(
    '- The user message includes the current date/time. Use it to resolve relative time expressions like "last hour", "yesterday", "past 7 days".',
  );

  if (options?.now) {
    lines.push("");
    lines.push(`## Current date/time`);
    lines.push("");
    lines.push(`Now: ${options.now.toISOString()}`);
    lines.push(
      'Use this to resolve relative time expressions like "last hour", "yesterday", "past 7 days".',
    );
  }

  return lines.join("\n");
}

/**
 * Generates a ready-to-use system prompt string from a table schema.
 *
 * The prompt describes all filterable columns and the expected output format
 * for an LLM to translate natural language queries into structured filter state.
 *
 * @param schema - Table schema definition (from `createTableSchema().definition`)
 * @param options - Optional config. Pass `{ now: new Date() }` to enable relative time resolution.
 */
export function generateAIPrompt(
  schema: TableSchemaDefinition,
  options?: GenerateAIPromptOptions,
): string {
  const context = generateAIContext(schema);
  return formatPrompt(context, options);
}
src/lib/ai/output-schema.ts
import {
  resolveColumn,
  type TableSchemaDefinition,
} from "@/lib/table-schema";
import { z } from "zod";

/**
 * Generates a Zod schema for LLM structured output from a table schema.
 *
 * Each filterable column becomes an optional field in the output schema:
 * - `input` (string) → `z.string().optional()`
 * - `input` (number) → `z.number().optional()`
 * - `checkbox` (enum) → `z.array(z.enum([...values])).optional()`
 * - `checkbox` (boolean) → `z.array(z.enum(["true", "false"])).optional()`
 * - `checkbox` (number) → `z.array(z.number()).optional()`
 * - `slider` → `z.tuple([z.number(), z.number()]).optional()`
 * - `timerange` → `z.tuple([z.string(), z.string()]).optional()`
 *
 * Command-disabled columns are intentionally included so the AI can infer
 * filters that aren't available in the command palette (e.g. date ranges).
 */
export function generateAIOutputSchema(schema: TableSchemaDefinition) {
  const shape: Record<string, z.ZodType> = {};

  for (const [key, builder] of Object.entries(schema)) {
    const config = resolveColumn(builder);
    if (!config.filter) continue;

    const filterType = config.filter.type;
    const desc = config.description || config.label || key;

    switch (filterType) {
      case "input": {
        if (config.kind === "number") {
          shape[key] = z.number().optional().describe(desc);
        } else {
          shape[key] = z.string().optional().describe(desc);
        }
        break;
      }

      case "checkbox": {
        const options = config.filter.options;
        if (options && options.length >= 2) {
          const values = options.map((o) => String(o.value));
          shape[key] = z
            .array(z.enum(values as [string, ...string[]]))
            .optional()
            .describe(desc);
        } else if (options && options.length === 1) {
          shape[key] = z
            .array(z.literal(String(options[0]!.value)))
            .optional()
            .describe(desc);
        } else if (config.kind === "number") {
          shape[key] = z.array(z.number()).optional().describe(desc);
        } else {
          shape[key] = z.array(z.string()).optional().describe(desc);
        }
        break;
      }

      case "slider": {
        const parts = [desc];
        if (
          config.filter.min !== undefined ||
          config.filter.max !== undefined
        ) {
          const bounds = [];
          if (config.filter.min !== undefined)
            bounds.push(`min: ${config.filter.min}`);
          if (config.filter.max !== undefined)
            bounds.push(`max: ${config.filter.max}`);
          parts.push(`Range: ${bounds.join(", ")}`);
        }
        if (config.filter.unit) parts.push(`Unit: ${config.filter.unit}`);

        shape[key] = z
          .tuple([z.number(), z.number()])
          .optional()
          .describe(parts.join(". "));
        break;
      }

      case "timerange": {
        shape[key] = z
          .tuple([z.string(), z.string()])
          .optional()
          .describe(desc + ". ISO 8601 datetime strings [start, end].");
        break;
      }
    }
  }

  return z.object(shape as z.core.$ZodLooseShape);
}
src/lib/ai/diff-partial.ts
import {
  resolveColumn,
  type TableSchemaDefinition,
} from "@/lib/table-schema";

export type CompletedField = {
  key: string;
  value: unknown;
};

/**
 * Compares two partial objects from an LLM stream and returns newly completed fields.
 *
 * Uses type-aware completeness rules based on the table schema's filter type:
 * - `input` (string/number): complete when non-null and non-undefined
 * - `checkbox` (array): complete on first value, updates as more arrive
 * - `slider` (tuple[2]): complete only when both values are present
 * - `timerange` (tuple[2]): complete only when both values are present
 *
 * @param prev - Previous partial state from the stream (or `{}` for first chunk)
 * @param next - Current partial state from the stream
 * @param schema - Table schema definition for type-aware completeness checks
 * @returns Array of newly completed or updated fields ready for `adapter.setField()`
 */
export function diffPartialState(
  prev: Record<string, unknown>,
  next: Record<string, unknown>,
  schema: TableSchemaDefinition,
): CompletedField[] {
  const completed: CompletedField[] = [];

  for (const [key, builder] of Object.entries(schema)) {
    const config = resolveColumn(builder);
    if (!config.filter) continue;

    const nextVal = next[key];
    const prevVal = prev[key];

    // Skip if the field hasn't appeared yet
    if (nextVal === undefined || nextVal === null) continue;

    const filterType = config.filter.type;

    switch (filterType) {
      case "input": {
        // Complete when non-null. Emit only if changed.
        if (nextVal !== prevVal) {
          completed.push({ key, value: nextVal });
        }
        break;
      }

      case "checkbox": {
        // Array — complete on first value, update as more arrive
        if (!Array.isArray(nextVal)) break;
        if (nextVal.length === 0) break;

        const prevArr = Array.isArray(prevVal) ? prevVal : [];
        if (
          nextVal.length !== prevArr.length ||
          nextVal.some((v, i) => v !== prevArr[i])
        ) {
          completed.push({ key, value: nextVal });
        }
        break;
      }

      case "slider": {
        // Tuple[2] — wait for both values
        if (!Array.isArray(nextVal)) break;
        if (nextVal.length < 2) break;
        if (typeof nextVal[0] !== "number" || typeof nextVal[1] !== "number")
          break;

        const prevArr = Array.isArray(prevVal) ? prevVal : [];
        if (nextVal[0] !== prevArr[0] || nextVal[1] !== prevArr[1]) {
          completed.push({ key, value: nextVal });
        }
        break;
      }

      case "timerange": {
        // Tuple[2] — wait for both date strings
        if (!Array.isArray(nextVal)) break;
        if (nextVal.length < 2) break;
        if (typeof nextVal[0] !== "string" || typeof nextVal[1] !== "string")
          break;

        const prevArr = Array.isArray(prevVal) ? prevVal : [];
        if (nextVal[0] !== prevArr[0] || nextVal[1] !== prevArr[1]) {
          completed.push({ key, value: nextVal });
        }
        break;
      }
    }
  }

  return completed;
}
src/lib/ai/parse-response.ts
import {
  resolveColumn,
  type TableSchemaDefinition,
} from "@/lib/table-schema";

/**
 * Validates and coerces a complete LLM response into clean filter state.
 *
 * - Strips fields not in the schema or not filterable
 * - Converts ISO date strings to Date objects for timerange fields
 * - Validates checkbox values against allowed options
 * - Clamps slider values to min/max bounds
 * - Returns `null` if the response is empty (no valid filters)
 */
export function parseAIResponse(
  schema: TableSchemaDefinition,
  response: Record<string, unknown>,
): Record<string, unknown> | null {
  const result: Record<string, unknown> = {};
  let hasFields = false;

  for (const [key, builder] of Object.entries(schema)) {
    const config = resolveColumn(builder);
    if (!config.filter) continue;

    const value = response[key];
    if (value === undefined || value === null) continue;

    const filterType = config.filter.type;

    switch (filterType) {
      case "input": {
        if (config.kind === "number") {
          if (typeof value === "number") {
            result[key] = value;
            hasFields = true;
          }
        } else {
          if (typeof value === "string" && value.length > 0) {
            result[key] = value;
            hasFields = true;
          }
        }
        break;
      }

      case "checkbox": {
        if (!Array.isArray(value)) break;
        if (value.length === 0) break;

        const options = config.filter.options;
        if (options && options.length > 0) {
          // Build a map from string representation to original typed value
          const optionMap = new Map(
            options.map((o) => [String(o.value), o.value]),
          );
          // Match AI strings against allowed values, coerce back to original type
          const valid = value
            .map(String)
            .filter((v) => optionMap.has(v))
            .map((v) => optionMap.get(v)!);
          if (valid.length > 0) {
            result[key] = valid;
            hasFields = true;
          }
        } else {
          result[key] = value;
          hasFields = true;
        }
        break;
      }

      case "slider": {
        if (!Array.isArray(value)) break;
        if (value.length < 2) break;

        let [min, max] = value as [number, number];
        if (typeof min !== "number" || typeof max !== "number") break;

        // Clamp to bounds
        if (config.filter.min !== undefined) {
          min = Math.max(min, config.filter.min);
        }
        if (config.filter.max !== undefined) {
          max = Math.min(max, config.filter.max);
        }

        if (min > max) break;

        result[key] = [min, max];
        hasFields = true;
        break;
      }

      case "timerange": {
        if (!Array.isArray(value)) break;
        if (value.length < 2) break;

        const [startStr, endStr] = value as [string, string];
        if (typeof startStr !== "string" || typeof endStr !== "string") break;

        const start = new Date(startStr);
        const end = new Date(endStr);

        if (isNaN(start.getTime()) || isNaN(end.getTime())) break;
        if (start > end) break;

        result[key] = [start, end];
        hasFields = true;
        break;
      }
    }
  }

  return hasFields ? result : null;
}
src/lib/ai/detect.ts
import {
  resolveColumn,
  type TableSchemaDefinition,
} from "@/lib/table-schema";

/**
 * Determines if the user's input is a structured `key:value` filter query
 * or natural language that should be sent to an AI endpoint.
 *
 * Returns `true` if the input matches the structured query format (i.e.,
 * contains a known field name followed by `:`). Returns `false` for natural
 * language input.
 *
 * @param input - The raw user input from the command palette
 * @param schema - Table schema definition to derive known field names
 */
export function isStructuredQuery(
  input: string,
  schema: TableSchemaDefinition,
): boolean {
  const trimmed = input.trim();
  if (trimmed.length === 0) return false;

  // Collect known filterable field keys
  const fieldKeys = new Set<string>();
  for (const [key, builder] of Object.entries(schema)) {
    const config = resolveColumn(builder);
    if (config.filter && !config.filter.commandDisabled) {
      fieldKeys.add(key);
    }
  }

  // Check if any token in the input starts with `knownField:`
  // This handles both `regions:ams,gru` and `regions:ams latency:100-500`
  const tokens = trimmed.split(/\s+/);
  for (const token of tokens) {
    const colonIndex = token.indexOf(":");
    if (colonIndex > 0) {
      const field = token.substring(0, colonIndex);
      if (fieldKeys.has(field)) {
        return true;
      }
    }
  }

  return false;
}
src/lib/ai/create-ai-filter-handler.ts
import type { TableSchemaDefinition } from "@/lib/table-schema";
import { streamObject } from "ai";
import type { LanguageModel } from "ai";
import { generateAIOutputSchema } from "./output-schema";
import { generateAIPrompt } from "./prompt";

export type AIFilterHandlerOptions = {
  model: LanguageModel;
  schema: TableSchemaDefinition;
};

/**
 * Creates a POST handler that streams AI-inferred filter state from a natural language query.
 *
 * The consumer provides a model and table schema. The handler generates the system prompt
 * and Zod output schema automatically, then streams the structured object response.
 *
 * The static schema description is cached via Anthropic's prompt caching
 * (`cache_control: { type: "ephemeral" }`). Only the dynamic timestamp and
 * user query are sent fresh on each request.
 *
 * @example
 * ```ts
 * import { createAnthropic } from "@ai-sdk/anthropic";
 * import { createAIFilterHandler } from "@/lib/ai/create-ai-filter-handler";
 * import { tableSchema } from "../table-schema";
 *
 * const anthropic = createAnthropic({
 *   baseURL: "https://ai-gateway.vercel.sh/v1",
 *   apiKey: process.env.AI_GATEWAY_API_KEY,
 * });
 *
 * export const POST = createAIFilterHandler({
 *   model: anthropic("anthropic/claude-opus-4.5"),
 *   schema: tableSchema.definition,
 * });
 * ```
 */
export function createAIFilterHandler({
  model,
  schema,
}: AIFilterHandlerOptions) {
  // Static prompt (cacheable) — generated once, same for every request
  const staticPrompt = generateAIPrompt(schema);
  const outputSchema = generateAIOutputSchema(schema);

  return async function POST(req: Request) {
    const body = await req.json();
    const query = typeof body?.query === "string" ? body.query.trim() : "";

    if (!query || query.length > 500) {
      return Response.json(
        { error: "Invalid query: must be a non-empty string (max 500 chars)" },
        { status: 400 },
      );
    }

    const now = new Date().toISOString();

    try {
      const result = streamObject({
        model,
        schema: outputSchema,
        messages: [
          {
            role: "system",
            content: staticPrompt,
            providerOptions: {
              anthropic: { cacheControl: { type: "ephemeral" } },
            },
          },
          {
            role: "user",
            content: `Current date/time: ${now}\n\nQuery: ${query}`,
          },
        ],
      });

      return result.toTextStreamResponse();
    } catch (error) {
      const message =
        error instanceof Error ? error.message : "AI filter inference failed";
      const status =
        error && typeof error === "object" && "statusCode" in error
          ? (error.statusCode as number)
          : 500;

      return Response.json({ error: message }, { status });
    }
  };
}

More from data-table-filters

Browse all components in this library →