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 · lightai@^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:"a b"</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 });
}
};
}