Easily integrate AG Grid with your own LLM, enabling end users to query and manipulate grid state via natural language.
The example below demonstrates a chat application, built using the AI Toolkit APIs and integrated with ChatGPT's gpt-5-mini model, that allows for manipulation of grid state via natural language.
This example does not maintain conversation state. If the LLM responds with a question, please update your initial query, instead of only answering the question.
Suggested prompts:
- "Show me all the gold medals won by the USA"
- "Sort the competitors with the youngest first"
- "Group by country and show the total number of medals won"
import { createApp, ref } from "vue";
import type { GridApi } from "ag-grid-community";
import {
ModuleRegistry,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { AllEnterpriseModule } from "ag-grid-enterprise";
import { callChatGPT } from "./chatgptApi";
import { type IOlympicData, gridOptions } from "./gridOptions";
import "./styles.css";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([AllEnterpriseModule]);
const App = {
setup() {
const gridApi = ref<GridApi | null>(null);
const naturalLanguageInput = ref("");
const aiResponse = ref("");
const processingStatus = ref("");
const currentState = ref("");
const isProcessing = ref(false);
const processRequest = async (event?: Event) => {
event?.preventDefault();
const userRequest = naturalLanguageInput.value.trim();
if (!userRequest) {
aiResponse.value = '<p style="color: red;">Please enter a request</p>';
return;
}
if (!gridApi.value) {
aiResponse.value = '<p style="color: red;">Grid not initialized</p>';
return;
}
isProcessing.value = true;
processingStatus.value =
'<code class="process">Processing request with ChatGPT <b>â§</b></code>';
aiResponse.value = "";
const currentGridState = gridApi.value.getState();
try {
const response = await callChatGPT(
userRequest,
currentGridState,
gridApi.value,
);
if (response.gridState && Object.keys(response.gridState).length > 0) {
gridApi.value.setState(
response.gridState,
response.propertiesToIgnore,
);
}
processingStatus.value =
'<code class="success">Request processed successfully! <b>â</b></code>';
aiResponse.value = `
<i class="prompt">Prompt</i>
<p class="msg prompt">${userRequest}</p>
<i class="response">Response</i>
<p class="msg response">${response.explanation}</p>
`;
naturalLanguageInput.value = "";
} catch (error) {
processingStatus.value =
'<code class="error">Error processing request <b>â</b></code>';
aiResponse.value = `<p>Error: ${error instanceof Error ? error.message : String(error)}</p>`;
} finally {
isProcessing.value = false;
}
};
const getCurrentState = () => {
if (gridApi.value) {
const state = gridApi.value.getState();
currentState.value = `<h4>Current Grid State:</h4><pre>${JSON.stringify(state, null, 2)}</pre>`;
}
};
const resetGrid = () => {
if (gridApi.value) {
gridApi.value.setState({
columnVisibility: { hiddenColIds: [] },
columnPinning: { leftColIds: [], rightColIds: [] },
sort: { sortModel: [] },
filter: { filterModel: {} },
rowGroup: { groupColIds: [] },
pagination: { page: 0, pageSize: 20 },
});
aiResponse.value = "";
processingStatus.value = "";
currentState.value = "";
}
};
const initializeGrid = () => {
const gridDiv = document.querySelector<HTMLElement>("#myGrid")!;
gridApi.value = createGrid(gridDiv, gridOptions);
fetch("https://www.ag-grid.com/example-assets/olympic-winners.json")
.then((response) => response.json())
.then((data: IOlympicData[]) => {
if (gridApi.value) {
gridApi.value.setGridOption("rowData", data);
}
});
};
return {
naturalLanguageInput,
aiResponse,
processingStatus,
currentState,
isProcessing,
processRequest,
getCurrentState,
resetGrid,
initializeGrid,
};
},
template: `
<div class="example-wrapper">
<div class="example-controls">
<div class="request-container">
<form class="input-group" @submit.prevent="processRequest">
<input
type="text"
v-model="naturalLanguageInput"
:disabled="isProcessing"
placeholder="Your prompt e.g. 'hide age column'"
/>
<button type="submit" :disabled="isProcessing">â</button>
</form>
<div id="processingStatus" v-html="processingStatus"></div>
<div>
<button @click="resetGrid">Reset Grid</button>
</div>
</div>
<div class="response-container">
<div id="aiResponse" v-if="aiResponse" v-html="aiResponse"></div>
<div id="currentState" v-if="currentState" v-html="currentState"></div>
</div>
</div>
<div id="myGrid"></div>
</div>
`,
mounted() {
this.initializeGrid();
},
};
createApp(App).mount("#app");
/**
* Styles for control elements in examples, not required for the examples' functionality
*/
:root {
--main-fg: #101828;
--main-bg: #fff;
--chart-bg: #fff;
--chart-border: #d0d5dd;
--button-fg: #212529;
--button-bg: transparent;
--button-border: #d0d5dd;
--button-hover-bg: rgba(0, 0, 0, 0.1);
--input-accent: #0e4491;
--input-focus-border: #3d7acd;
--range-track-bg: #efefef;
--row-gap: 6px;
--select-chevron: url('data:image/svg+xml;utf8,<svg fill="none" stroke="%23667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 9L12 15L18 9"/></svg>');
--checkbox-tick-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17L4 12'/%3E%3C/svg%3E");
--success: #0759c2;
--error: #dc0505;
}
:root[data-color-scheme='dark'] {
--main-fg: #fff;
--main-bg: #141d2c;
--chart-bg: #192232;
--chart-border: #344054;
--button-fg: #f8f9fa;
--button-bg: transparent;
--button-border: rgba(255, 255, 255, 0.2);
--button-hover-bg: #2a343e;
--input-accent: #a9c5ec;
--input-focus-border: #3d7acd;
--range-track-bg: #4a5465;
--select-chevron: url('data:image/svg+xml;utf8,<svg fill="none" stroke="%239CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 9L12 15L18 9"/></svg>');
--checkbox-tick-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232a343e' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17L4 12'/%3E%3C/svg%3E");
--success: #9bc7ff;
--error: #ff7878;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
:root,
body {
height: 100%;
width: 100%;
margin: 0;
overflow: hidden;
}
/* Hide codesandbox highlighter */
body > #highlighter {
display: none;
}
.example-controls {
display: flex;
flex-direction: column;
flex-wrap: wrap;
}
.example-controls *,
.example-controls *::before,
.example-controls *::after {
margin: 0 !important;
font-family: -apple-system, 'system-ui', sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 17px;
letter-spacing: 0.01em;
color: var(--main-fg);
}
.example-controls :where(button, textarea, select, input[type='submit'], input[type='text'], input[type='number']) {
appearance: none;
display: inline-block;
height: 36px;
padding: 5px 14px 7px;
white-space: nowrap;
border-radius: 6px;
color: var(--button-fg) !important;
background-color: var(--button-bg);
border: 1px solid var(--button-border);
box-shadow: 0 0 0 0 transparent;
transition:
background-color 0.25s ease-in-out,
border-color 0.25s ease-in-out,
box-shadow 0.25s ease-in-out;
align-self: flex-start;
}
.example-controls :where(button, select, input[type='submit']) {
cursor: pointer;
}
.example-controls select {
appearance: none;
padding-right: 32px;
padding-left: 14px;
background: no-repeat center right 4px var(--select-chevron);
}
.example-controls textarea {
height: auto;
padding: 7px 14px;
}
.example-controls pre,
.example-controls code {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.example-controls input {
appearance: none;
}
.example-controls input[type='checkbox'],
.example-controls input[type='radio'] {
border: 1px solid var(--button-border);
cursor: pointer;
}
.example-controls input[type='radio'] {
width: 20px;
height: 20px;
border-radius: 50%;
}
.example-controls input[type='radio']:checked {
border-width: 0;
box-shadow: inset 0 0 0 6px var(--input-accent);
}
.example-controls input[type='radio']:checked:focus-visible {
box-shadow:
inset 0 0 0 2px var(--input-focus-border),
inset 0 0 0 3px var(--main-bg),
inset 0 0 0 6px var(--input-accent);
}
.example-controls input[type='checkbox'] {
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
}
.example-controls input[type='checkbox']:checked {
background: var(--input-accent) no-repeat center/14px var(--checkbox-tick-icon);
border-color: var(--input-accent);
}
.example-controls input[type='range'] {
appearance: none;
min-width: 160px;
border-radius: 8px;
cursor: pointer;
overflow: hidden; /* slider progress trick */
background: var(--range-track-bg);
}
.example-controls input[type='range']::-webkit-slider-runnable-track {
appearance: none;
height: 16px;
background: var(--range-track-bg);
}
.example-controls input[type='range']::-moz-range-track {
appearance: none;
height: 16px;
background: var(--range-track-bg);
}
.example-controls input[type='range']::-webkit-slider-thumb {
appearance: none;
height: 16px;
width: 16px;
background-color: var(--main-bg);
border-radius: 50%;
border: 2px solid var(--input-accent);
box-shadow: -1007px 0 0 1000px var(--input-accent); /* slider progress trick */
}
.example-controls input[type='range']::-moz-range-thumb {
appearance: none;
height: 16px;
width: 16px;
background-color: var(--main-bg);
border-radius: 50%;
border: 2px solid var(--input-accent);
box-shadow: -1007px 0 0 1000px var(--input-accent); /* slider progress trick */
}
.example-controls :is(button, input[type='submit'], select):hover {
background-color: var(--button-hover-bg);
}
.example-controls :is(button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible) {
border-color: var(--input-focus-border) !important;
box-shadow:
inset 0 0 0 1px var(--input-focus-border),
inset 0 0 0 2px var(--main-bg);
outline: none;
}
.controls-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--row-gap);
font-variant: tabular-nums;
}
.controls-row + .controls-row {
margin-top: var(--row-gap);
}
.controls-row.center {
justify-content: center;
}
.controls-row .push-right {
margin-left: auto;
}
.controls-row .push-left {
margin-right: auto;
}
.controls-row .gap-right {
margin-right: calc(var(--row-gap) * 6);
}
.controls-row .gap-left {
margin-left: calc(var(--row-gap) * 6);
}
/* Additional Styles */
#myGrid {
flex: 1;
}
.example-wrapper {
height: 100%;
display: flex;
flex-direction: column;
gap: var(--row-gap);
}
.example-controls {
display: flex;
flex-direction: row;
width: 100%;
gap: calc(var(--row-gap) * 4);
}
.example-controls .request-container {
flex: 1;
display: flex;
flex-direction: column;
}
.example-controls .response-container {
flex: 1;
display: flex;
flex-direction: column;
}
.input-group {
display: flex;
}
.input-group :not(:last-child) {
border-right: 0;
}
.input-group :first-child {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.input-group :last-child {
margin-left: -1px !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.input-group input {
flex: 1;
}
.input-group input::placeholder {
font-style: italic;
}
.request-container {
display: flex;
flex-direction: column;
gap: var(--row-gap);
}
.request-container > div:last-of-type {
margin-top: auto !important;
}
#processingStatus {
min-height: 28px;
}
#processingStatus code {
--fg: var(--main-fg);
display: flex;
padding: 5px 14px 4px;
font-size: 12px;
border-radius: 4px;
background: color-mix(in srgb, var(--fg) 5%, transparent);
color: var(--fg);
border: 1px solid color-mix(in srgb, var(--fg) 15%, transparent);
}
#processingStatus code b {
margin-left: auto !important;
color: var(--fg);
line-height: 1;
}
#processingStatus code.process b {
transform-origin: 5.4px 7.2px;
animation-name: spin;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
#processingStatus code.success {
--fg: var(--success);
}
#processingStatus code.error {
--fg: var(--error);
}
#processRequest {
display: flex;
align-items: center;
justify-content: center;
}
.response-container {
padding: 4px;
border-radius: 6px;
border: 1px dashed var(--chart-border);
}
.response-container {
display: flex;
flex-direction: column;
gap: var(--row-gap);
}
#aiResponse i,
#aiResponse .msg {
font-size: 12px;
}
#aiResponse i {
display: block;
color: color-mix(in srgb, var(--main-fg) 50%, transparent);
}
#aiResponse .msg {
padding: 6px;
border-radius: 4px;
background: color-mix(in srgb, var(--main-fg) 3%, transparent);
color: var(--main-fg);
border: 1px solid color-mix(in srgb, var(--main-fg) 10%, transparent);
}
#aiResponse .prompt {
margin-right: 32px !important;
}
#aiResponse .response {
margin-left: 32px !important;
}
#aiResponse i.response {
text-align: right;
}
#aiResponse .msg.prompt {
margin-bottom: 12px !important;
}
import type { GridApi } from "ag-grid-community";
const BASE_URL = "https://ai-api.ag-grid.com/api/openai/v1";
const AI_API_TOKEN = "";
const ajv = new ajv7({
validateSchema: true, // Validate schemas against meta-schema
strict: true,
});
export async function callChatGPT(
userRequest: string,
currentState: any,
gridApi: GridApi,
): Promise<any> {
const { $defs, ...structuredSchema } = gridApi.getStructuredSchema({
columns: {
sport: {
includeSetValues: true,
},
country: {
includeSetValues: true,
},
},
});
const {
aggregation,
rowGroup,
columnSizing,
columnVisibility,
sort,
filter,
pivot,
} = currentState;
const state = {
aggregation,
rowGroup,
columnSizing,
columnVisibility,
sort,
filter,
pivot,
};
const schema = {
type: "object",
$defs,
properties: {
gridState: structuredSchema,
propertiesToIgnore: {
type: "array",
items: {
type: "string",
enum: [
"aggregation",
"filter",
"sort",
"pivot",
"columnVisibility",
"columnSizing",
"rowGroup",
],
},
description:
"List of grid state properties to ignore when applying the new state",
},
explanation: {
type: "string",
description:
"Human-readable explanation of the changes made to the grid state",
},
},
required: ["gridState", "explanation", "propertiesToIgnore"],
additionalProperties: false,
};
const systemPrompt = `
You are an assistant for a table displaying Olympic medal results. You help users modify grid configuration to fit their needs.
The schema provided can be used to manipulate multiple features of the table to help the user with their query.
Current grid state: ${JSON.stringify(state)}
Respond with only the necessary state changes, not the complete state. Provide a clear explanation of what you changed.
Any unchanged properties that are present in the current state must be included in \`propertiesToIgnore\`. Otherwise they will be removed from the state.
Important: Only modify the properties that the user specifically requested. If they ask to "hide the age column", only include columnVisibility in your response, not other unrelated properties.
Where possible, augment the provided state `;
let result;
try {
result = await generateObject({
model: "gpt-5-mini",
schema,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userRequest,
},
],
});
} catch (error: any) {
throw new Error(`OpenAI API error: ${error.message || "Unknown error"}`);
}
return result;
}
async function generateObject(options: any): Promise<any> {
const {
model = "gpt-4o-mini",
schema,
messages,
maxTokens = 4096,
stream = false,
} = options;
const requestBody = {
model,
messages,
max_completion_tokens: maxTokens,
response_format: schema
? {
type: "json_schema",
json_schema: {
name: "grid_state_response",
schema,
},
}
: { type: "json_object" },
stream,
};
const url = `${BASE_URL}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(AI_API_TOKEN ? { Authorization: `Bearer ${AI_API_TOKEN}` } : {}),
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: "Unknown error" }));
const error =
errorData.error?.code === "rate_limit_exceeded"
? "OpenAI Rate Limit Exceeded"
: `OpenAI API error: ${response.status} - ${errorData.error?.message || "Unknown error"}`;
throw new Error(error);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error("No content received from OpenAI API");
}
let parsedObject;
try {
parsedObject = JSON.parse(content);
} catch (error) {
throw new Error(
`Failed to parse JSON response: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
return parsedObject;
}
import type { GridOptions } from "ag-grid-community";
export interface IOlympicData {
athlete: string;
age: number;
country: string;
year: number;
sport: string;
gold: number;
silver: number;
bronze: number;
total: number;
}
export const gridOptions: GridOptions<IOlympicData> = {
columnDefs: [
{
field: "athlete",
minWidth: 200,
filter: "agTextColumnFilter",
enableRowGroup: true,
enablePivot: false,
},
{
field: "age",
width: 90,
filter: "agNumberColumnFilter",
enableValue: true,
enableRowGroup: false,
},
{
field: "country",
minWidth: 150,
filter: "agSetColumnFilter",
enableRowGroup: true,
enablePivot: true,
},
{
field: "year",
width: 90,
filter: "agNumberColumnFilter",
enableRowGroup: true,
enableValue: false,
},
{
field: "sport",
minWidth: 150,
filter: "agSetColumnFilter",
enableRowGroup: true,
enablePivot: true,
},
{
field: "gold",
width: 100,
filter: "agNumberColumnFilter",
enableValue: true,
aggFunc: "sum",
},
{
field: "silver",
width: 100,
filter: "agNumberColumnFilter",
enableValue: true,
aggFunc: "sum",
},
{
field: "bronze",
width: 100,
filter: "agNumberColumnFilter",
enableValue: true,
aggFunc: "sum",
},
{
field: "total",
width: 100,
filter: "agNumberColumnFilter",
enableValue: true,
aggFunc: "sum",
},
],
defaultColDef: {
flex: 1,
minWidth: 100,
filter: true,
sortable: true,
resizable: true,
},
enableFilterHandlers: true,
sideBar: {
toolPanels: ["columns", "filters-new"],
},
};
How It Works Copy Link
Structured Outputs is an LLM feature that ensures model responses adhere to a supplied JSON Schema. Structured Outputs are supported by many LLMs, including ChatGPT and Gemini.
The AI Toolkit provides a getStructuredSchema API that generates a structured schema, based on grid state. These outputs can be passed to an LLM, which can then generate valid responses that can be passed directly to the setState API method. This ensures reliable, schema-aligned instructions for updating or manipulating the grid based on natural language input.
The schema is made up of a series of "features", each representing a different aspect of the grid that can be manipulated. The following features are currently supported:
Architecture Copy Link
At a high level, the AI Toolkit works as follows:
User Input Capture: End user enters a natural language query.
Prompt Construction (Client/Server): The application gathers three elements:
- The user query
- The current grid state, via
gridApi.getState() - The structured schema of grid state, via
gridApi.getStructuredSchema()
LLM Service Request: The complete prompt (including the structured schema) is sent to the LLM endpoint (e.g., OpenAI, Gemini).
Response Processing & Validation: The LLM returns a JSON object conforming to the schema.
State Application: The validated JSON is passed to
gridApi.setState().
If you wish to integrate with an LLM that does not support structured data natively, you can still use the schema to validate and parse the response from the LLM before passing it to setState. There are multiple libraries that can do this for you, such as ajv.
Getting Started Copy Link
To get started you'll need:
- API Key for your chosen LLM
- Existing AG Grid implementation
- Input for user queries
- Knowledge of making requests to an LLM
Creating a Schema Copy Link
The getStructuredSchema API returns a structured JSON Schema representation of the Grid State that can then be used to create a LLM compatible schema:
// Generated Structured Schema Representation of Grid State
const gridStateStructuredSchema = gridApi.getStructuredSchema();
// Create LLM compatible JSON Schema, using Grid State Structured Schema
const schema = {
type: 'object',
properties: {
gridState: gridStateStructuredSchema,
propertiesToIgnore: {
type: 'array',
items: {
type: 'string',
enum: ['aggregation', 'filter', 'sort', 'pivot', 'columnVisibility', 'columnSizing', 'rowGroup'],
},
description: 'List of grid state properties to ignore when applying the new state',
},
explanation: {
type: 'string',
description: 'Human-readable explanation of the changes made to the grid state',
},
},
required: ['gridState', 'propertiesToIgnore', 'explanation'],
additionalProperties: false,
};The getStructuredSchema() API returns a narrow representation of what can be achieved using the grid's API. For example, if a column is not sortable, the schema will not include that column in the list of sortable columns. This ensures that the LLM is only able to generate valid state changes for the grid.
The schema contains several properties for the LLM to populate:
gridStatea structured output representation of grid state.propertiesToIgnorea list of grid state properties which have been unchanged, to ensure they are not overridden when updating grid state (optional, but recommended).explanationa string the LLM can use to provide human-readable context to the user about the changes that have been applied (optional, but recommended).
Modifying State with Any LLM Copy Link
To provide the LLM with sufficient context, we recommend sending the users' request, the current grid state, and the schema to the LLM:
// Get Users' Request, e.g. from Input Element
const userRequest = inputElement.value.trim();
// Get Current Grid State
const gridState = gridApi.getState();
// Send User Request & Schema to LLM
const response = await callLLM(userRequest, gridState, schema)The callLLM function needs to be implemented in accordance with your chosen LLM. Refer to our example above for a reference implementation using ChatGPT's Completions API and the Prompting section for more information on creating system prompts.
Updating Grid State Copy Link
Once the LLM has provided a response, it should be validated against your top-level schema. This is particularly important when using an LLM that does not support structured outputs.
In this example, we're using ajv to validate the LLMs response, before calling setState to update the grid:
// Extract Grid State & Properties to Ignore from LLM Response
const { newGridState, propertiesToIgnore } = response;
// Init ajv Validator with Schema
const ajv = new ajv7();
const ajvValidator = ajv.compile(schema);
// Validate LLM response w/ ajv
if (!ajvValidator(newGridState)) {
console.error("Invalid Schema")
return;
}
// Update Grid State with LLM Response
gridApi.setState(newGridState, propertiesToIgnore);Passing propertiesToIgnore (the properties that are unchanged by the LLM) to setState ensures that these properties are not overridden after the grid state is updated.
Excluding Features Copy Link
By default, all features are enabled and included in the schema. If you wish to limit the features returned in the schema, you can do so by providing a list of feature names to the getStructuredSchema method.
For example, if you do not want to allow users to manipulate column visibility and sorting, you can call getStructuredSchema like this:
const gridStateStructuredSchema = gridApi.getStructuredSchema(
{
exclude: ['columnVisibility', 'sorting']
}
); Providing Additional Context Copy Link
Occasionally the LLM will need more information than can be provided by the grid alone. As such, you can pass in options for each column defining extra context.
description- This provides a description of what the column contains to the LLM inline in the schema. This might include details such as the type or format of the data, to aid it when filtering or aggregating.includeSetValues- When using the Set Filter, the LLM must be provided with the allowed values for it to correctly set those it wishes to filter. However, some Set Filters contain many values which lead to a schema which is too large for your LLM to process. By default we do not include the Set Filter values, however if you set this property to true then they will be included. Refer to the Handling Schema Size Limits for more information.
const gridStateStructuredSchema = gridApi.getStructuredSchema({ columns: {
sport: {
description: "The sport the athlete won their medal in",
includeSetValues: true
},
gold: {
description: "The number of gold medals won by this athlete at this games"
}
}});Before including data like columns description or Set Filter values, you should be aware of the data security policy of your LLM provider.
Prompting Copy Link
The AI Toolkit does not include any prompting logic, as this will vary depending on the LLM you are using and your specific use case. This gives you the flexibility to craft prompts that are tailored to your users and the data in your grid.
In our testing we have found a few things that help get the best results from LLMs when prompting them to generate grid state changes:
- Include the current state of the grid in the prompt. This helps the LLM understand what the grid currently looks like, and what changes are being requested.
- Request the LLM to only return the grid state changes, and nothing else. This helps ensure that the response can be passed directly to
setState(). - You may wish to provide the LLM with a few rows of data from the grid, to help it understand the data it is working with. This is especially useful if your grid contains domain-specific data that the LLM may not be familiar with. If you have a small dataset, you can even include the entire dataset in the prompt by using
exportToCsv(). - If you have any domain specific knowledge or terminology that you want the LLM to be aware of, include that in the prompt as well. e.g. "In this dataset, a 'medal' refers to any of gold, silver or bronze medals won at the Olympic games".
- Including a list of available features also helps the LLM understand what it can and cannot do.
Below is an example prompt you can use as a starting point:
const prompt = `
You are an expert data analyst working with a data grid.
The grid contains data about Olympic athletes and their achievements.
You should respond to user requests by generating a JSON object that
represents their requested changes to the grid state. The response should
include all their requested changes, along with any features that are
already applied to the grid that they have not requested to change.
The following is the current state of a data grid, represented as a
JSON object. The grid contains data about Olympic athletes and their achievements.
Current Grid State:
${JSON.stringify(gridApi.getState(), null, 2)}
The grid has the following features available to manipulate:
- Column Visibility
- Column Sizing
- Row Grouping
- Sorting
- Aggregation
- Pivoting
- Filtering
` Modifying the Schema Copy Link
You may modify the structured schema to surgically change options or add extra features. For example, you may wish to add schema for a custom filter, or limit which columns can be hidden by the LLM:
// Generate base schema
const baseSchema = gridApi.getStructuredSchema();
// Augment with custom constraints
function applyCustomRules(schema) {
return {
...schema,
// custom schema rules...
}
const customSchema = applyCustomRules(baseSchema);If you choose to do this, make sure that the result is still a valid GridState object before passing it to setState. Be aware that the JSON schema supported by LLMs is a subset of the full JSON schema spec and build your schemas accordingly.
Handling Schema Size Limits Copy Link
includeSetValues: true is useful when the LLM must pick from explicit allowed values (e.g., Set Filter), enabling precise filter construction. However, large cardinalities can inflate the prompt and exceed context limits.
Recommendations:
Enable
includeSetValuesselectively on low-cardinality columns only.Consider truncating to the top N most frequent values plus an "OTHER" hint.
Monitor total prompt size; keep within your modelâs context window with a buffer for the modelâs response.
Example: AI Chat Assistant Copy Link
This example demonstrates an AI chat assistant embedded within the grid's Side Bar as a Custom Tool Panel. The assistant maintains conversation history, enabling multi-turn interactions where users can reference previous responses and refine their requests.
Try the following sequence of prompts to see how the assistant remembers context:
- "Suggest 3 different ways to analyse spending patterns in this data"
- "Apply suggestion 2"
- "Actually, undo that and try suggestion 3 instead"
- "Now add a filter to only show transactions over ÂŁ100"
import { createApp, ref } from "vue";
import { ModuleRegistry, enableDevValidations } from "ag-grid-community";
import { AllEnterpriseModule } from "ag-grid-enterprise";
import { AgGridVue } from "ag-grid-vue3";
import { ITransaction, generateTransactions } from "./generateTransactions";
import { gridOptions } from "./gridOptions";
import "./styles.css";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([AllEnterpriseModule]);
const App = {
components: {
AgGridVue,
},
setup() {
// Generate synthetic transaction data
const rowData = ref<ITransaction[]>(
generateTransactions({ count: 10000, seed: 42 }),
);
return {
rowData,
gridOptions: gridOptions,
};
},
template: `
<div style="width: 100%; height: 100%;">
<ag-grid-vue
style="width: 100%; height: 100%;"
:rowData="rowData"
:gridOptions="gridOptions"
/>
</div>
`,
};
createApp(App).mount("#app");
/**
* Styles for control elements in examples, not required for the examples' functionality
*/
:root {
--main-fg: #101828;
--main-bg: #fff;
--chart-bg: #fff;
--chart-border: #d0d5dd;
--button-fg: #212529;
--button-bg: transparent;
--button-border: #d0d5dd;
--button-hover-bg: rgba(0, 0, 0, 0.1);
--input-accent: #0e4491;
--input-focus-border: #3d7acd;
--range-track-bg: #efefef;
--row-gap: 6px;
--select-chevron: url('data:image/svg+xml;utf8,<svg fill="none" stroke="%23667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 9L12 15L18 9"/></svg>');
--checkbox-tick-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17L4 12'/%3E%3C/svg%3E");
--success: #0759c2;
--error: #dc0505;
}
:root[data-color-scheme='dark'] {
--main-fg: #fff;
--main-bg: #141d2c;
--chart-bg: #192232;
--chart-border: #344054;
--button-fg: #f8f9fa;
--button-bg: transparent;
--button-border: rgba(255, 255, 255, 0.2);
--button-hover-bg: #2a343e;
--input-accent: #a9c5ec;
--input-focus-border: #3d7acd;
--range-track-bg: #4a5465;
--select-chevron: url('data:image/svg+xml;utf8,<svg fill="none" stroke="%239CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 9L12 15L18 9"/></svg>');
--checkbox-tick-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232a343e' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17L4 12'/%3E%3C/svg%3E");
--success: #9bc7ff;
--error: #ff7878;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
:root,
body {
height: 100%;
width: 100%;
margin: 0;
overflow: hidden;
}
/* Hide codesandbox highlighter */
body > #highlighter {
display: none;
}
.example-controls {
display: flex;
flex-direction: column;
flex-wrap: wrap;
}
.example-controls *,
.example-controls *::before,
.example-controls *::after {
margin: 0 !important;
font-family: -apple-system, 'system-ui', sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 17px;
letter-spacing: 0.01em;
color: var(--main-fg);
}
.example-controls :where(button, textarea, select, input[type='submit'], input[type='text'], input[type='number']) {
appearance: none;
display: inline-block;
height: 36px;
padding: 5px 14px 7px;
white-space: nowrap;
border-radius: 6px;
color: var(--button-fg) !important;
background-color: var(--button-bg);
border: 1px solid var(--button-border);
box-shadow: 0 0 0 0 transparent;
transition:
background-color 0.25s ease-in-out,
border-color 0.25s ease-in-out,
box-shadow 0.25s ease-in-out;
align-self: flex-start;
}
.example-controls :where(button, select, input[type='submit']) {
cursor: pointer;
}
.example-controls select {
appearance: none;
padding-right: 32px;
padding-left: 14px;
background: no-repeat center right 4px var(--select-chevron);
}
.example-controls textarea {
height: auto;
padding: 7px 14px;
}
.example-controls pre,
.example-controls code {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.example-controls input {
appearance: none;
}
.example-controls input[type='checkbox'],
.example-controls input[type='radio'] {
border: 1px solid var(--button-border);
cursor: pointer;
}
.example-controls input[type='radio'] {
width: 20px;
height: 20px;
border-radius: 50%;
}
.example-controls input[type='radio']:checked {
border-width: 0;
box-shadow: inset 0 0 0 6px var(--input-accent);
}
.example-controls input[type='radio']:checked:focus-visible {
box-shadow:
inset 0 0 0 2px var(--input-focus-border),
inset 0 0 0 3px var(--main-bg),
inset 0 0 0 6px var(--input-accent);
}
.example-controls input[type='checkbox'] {
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
}
.example-controls input[type='checkbox']:checked {
background: var(--input-accent) no-repeat center/14px var(--checkbox-tick-icon);
border-color: var(--input-accent);
}
.example-controls input[type='range'] {
appearance: none;
min-width: 160px;
border-radius: 8px;
cursor: pointer;
overflow: hidden; /* slider progress trick */
background: var(--range-track-bg);
}
.example-controls input[type='range']::-webkit-slider-runnable-track {
appearance: none;
height: 16px;
background: var(--range-track-bg);
}
.example-controls input[type='range']::-moz-range-track {
appearance: none;
height: 16px;
background: var(--range-track-bg);
}
.example-controls input[type='range']::-webkit-slider-thumb {
appearance: none;
height: 16px;
width: 16px;
background-color: var(--main-bg);
border-radius: 50%;
border: 2px solid var(--input-accent);
box-shadow: -1007px 0 0 1000px var(--input-accent); /* slider progress trick */
}
.example-controls input[type='range']::-moz-range-thumb {
appearance: none;
height: 16px;
width: 16px;
background-color: var(--main-bg);
border-radius: 50%;
border: 2px solid var(--input-accent);
box-shadow: -1007px 0 0 1000px var(--input-accent); /* slider progress trick */
}
.example-controls :is(button, input[type='submit'], select):hover {
background-color: var(--button-hover-bg);
}
.example-controls :is(button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible) {
border-color: var(--input-focus-border) !important;
box-shadow:
inset 0 0 0 1px var(--input-focus-border),
inset 0 0 0 2px var(--main-bg);
outline: none;
}
.controls-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--row-gap);
font-variant: tabular-nums;
}
.controls-row + .controls-row {
margin-top: var(--row-gap);
}
.controls-row.center {
justify-content: center;
}
.controls-row .push-right {
margin-left: auto;
}
.controls-row .push-left {
margin-right: auto;
}
.controls-row .gap-right {
margin-right: calc(var(--row-gap) * 6);
}
.controls-row .gap-left {
margin-left: calc(var(--row-gap) * 6);
}
/* Additional Styles */
#myGrid {
height: 100%;
width: 100%;
}
/* Chat Tool Panel Styles */
.chat-tool-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--main-bg);
font-family: -apple-system, 'system-ui', sans-serif;
width: 400px;
}
.chat-header {
padding: 12px;
border-bottom: 1px solid var(--chart-border);
background: var(--main-bg);
}
.chat-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.chat-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--main-fg);
}
.chat-subtitle {
margin: 0;
font-size: 13px;
font-weight: 400;
color: var(--main-fg);
opacity: 0.7;
}
.chat-actions {
display: flex;
gap: 4px;
}
.icon-btn {
appearance: none;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--main-fg);
opacity: 0.6;
cursor: pointer;
transition: all 0.2s;
}
.icon-btn:hover {
opacity: 1;
background: var(--button-hover-bg);
border-color: var(--button-border);
}
.icon-btn:active {
transform: scale(0.95);
}
.icon-btn svg {
flex-shrink: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-message {
display: flex;
flex-direction: column;
max-width: 85%;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.user-message {
align-self: flex-end;
}
.assistant-message {
align-self: flex-start;
}
.message-bubble {
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.4;
word-wrap: break-word;
}
.user-message .message-bubble {
background: var(--ag-accent-color);
color: white;
border-bottom-right-radius: 4px;
}
.assistant-message .message-bubble {
background: color-mix(in srgb, var(--main-fg) 8%, transparent);
color: var(--main-fg);
border-bottom-left-radius: 4px;
}
.error-message .message-bubble {
background: color-mix(in srgb, var(--error) 15%, transparent);
color: var(--error);
border: 1px solid var(--error);
}
.loading-dots {
display: inline-flex;
align-items: center;
gap: 2px;
}
.loading-dots span {
animation: blink 1.4s infinite;
opacity: 0;
}
.loading-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dots span:nth-child(3) {
animation-delay: 0.4s;
}
.loading-dots span:nth-child(4) {
animation-delay: 0.6s;
}
@keyframes blink {
0%,
20% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.loading-disclaimer {
margin-top: 6px;
margin-left: 4px;
font-size: 11px;
font-style: italic;
opacity: 0.65;
line-height: 1.3;
display: flex;
align-items: start;
gap: 4px;
}
.info-icon {
font-style: normal;
font-size: 12px;
opacity: 0.8;
}
.chat-input-form {
display: flex;
align-items: end;
padding: 12px;
gap: 8px;
border-top: 1px solid var(--chart-border);
background: var(--main-bg);
}
.chat-input {
flex: 1;
padding: 10px 12px;
font-size: 12px;
width: 50%;
border: 1px solid var(--button-border);
border-radius: 8px;
background: var(--main-bg);
color: var(--main-fg);
outline: none;
transition: border-color 0.2s;
}
.chat-input:focus {
border-color: var(--input-focus-border);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--input-focus-border) 20%, transparent);
}
.chat-input::placeholder {
color: color-mix(in srgb, var(--main-fg) 40%, transparent);
font-style: italic;
}
.chat-submit {
appearance: none;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid var(--button-border);
background: var(--ag-accent-color);
color: white;
font-size: 18px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.chat-submit:hover {
background: color-mix(in srgb, var(--input-accent) 85%, black);
transform: translateX(2px);
}
.chat-submit:active {
transform: scale(0.95) translateX(2px);
}
.chat-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.chat-submit:disabled:hover {
background: var(--input-accent);
transform: none;
}
import { defineComponent, nextTick, onMounted, ref } from "vue";
import { GridApi, IToolPanelParams } from "ag-grid-community";
import { callChatGPT } from "./chatgptApi";
import { ChatMessage } from "./types";
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
// Store conversation history outside the component to persist across grid state changes
let conversationHistory: ChatMessage[] = [];
export const ChatToolPanel = defineComponent({
props: {
params: {
type: Object as () => IToolPanelParams,
required: true,
},
},
setup(props) {
const gridApi = ref<GridApi | null>(null);
const messages = ref<ChatMessage[]>([]);
const inputValue = ref("");
const isLoading = ref(false);
const chatMessagesRef = ref<HTMLDivElement | null>(null);
onMounted(() => {
gridApi.value = props.params.api;
// Sync local state with conversation history on mount
messages.value = [...conversationHistory];
});
const scrollToBottom = () => {
nextTick(() => {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
}
});
};
const handleSubmit = async () => {
const userMessage = inputValue.value.trim();
if (!userMessage || isLoading.value || !gridApi.value) return;
// Render user message
messages.value = [
...messages.value,
{ role: "user", content: userMessage },
];
inputValue.value = "";
isLoading.value = true;
scrollToBottom();
try {
const response = await callChatGPT(
userMessage,
gridApi.value,
conversationHistory,
);
// Log the LLM response
console.log("Explanation:", response.explanation);
if (response.gridState && Object.keys(response.gridState).length > 0) {
console.log("New Grid State: ", response.gridState);
}
if (response.propertiesToIgnore?.length > 0) {
console.log("Properties Ignored:", response.propertiesToIgnore);
}
// Add both messages to history after successful response
conversationHistory.push(
{ role: "user", content: userMessage },
{ role: "assistant", content: response.explanation },
);
// Apply grid state changes if any (this will destroy and recreate the tool panel)
// Messages will be automatically added when the tool panel reloads
if (response.gridState && Object.keys(response.gridState).length > 0) {
gridApi.value.setState(
response.gridState,
response.propertiesToIgnore,
);
} else {
// If no state change, manually update messages
messages.value = [...conversationHistory];
}
} catch (error) {
const errorMessage = `Error: ${error instanceof Error ? error.message : String(error)}`;
messages.value = [
...messages.value,
{ role: "assistant", content: errorMessage },
];
} finally {
isLoading.value = false;
scrollToBottom();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
};
const reset = () => {
// Reset conversation
conversationHistory = [];
messages.value = [];
inputValue.value = "";
// Reset grid state
if (!gridApi.value) return;
gridApi.value.setState({
columnVisibility: {
hiddenColIds: [
"ag-Grid-HierarchyColumn-transactionDate-year",
"ag-Grid-HierarchyColumn-transactionDate-year",
"ag-Grid-HierarchyColumn-transactionDate-formattedMonth",
"ag-Grid-HierarchyColumn-transactionDate-formattedMonth",
"currency",
],
},
columnPinning: { leftColIds: [], rightColIds: [] },
sort: { sortModel: [] },
filter: { filterModel: {} },
rowGroup: { groupColIds: [] },
pagination: { page: 0, pageSize: 100 },
});
};
return {
messages,
inputValue,
isLoading,
chatMessagesRef,
handleSubmit,
handleKeyDown,
reset,
};
},
template: `
<div class="chat-tool-panel">
<div class="chat-header">
<div class="chat-title-row">
<h3 class="chat-title">AI Assistant</h3>
<div class="chat-actions">
<button class="icon-btn reset-btn" title="Reset" aria-label="Reset" @click="reset">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
</button>
</div>
</div>
<p class="chat-subtitle">
This example demonstrates the AI Toolkit with conversation history, embedded in a custom tool panel.
</p>
</div>
<div class="chat-messages" ref="chatMessagesRef">
<div
v-for="(message, index) in messages"
:key="index"
:class="['chat-message', message.role + '-message']"
>
<div class="message-bubble">{{ message.content }}</div>
</div>
<div v-if="isLoading" class="chat-message assistant-message loading-message">
<div class="message-bubble">
<span class="loading-dots">Thinking<span>.</span><span>.</span><span>.</span></span>
</div>
<div class="loading-disclaimer">
<span class="info-icon">i</span> This demo uses a proxy, so responses may take up to 30 seconds
</div>
</div>
</div>
<form class="chat-input-form" @submit.prevent="handleSubmit">
<textarea
rows="4"
class="chat-input"
placeholder="Ask me anything, e.g. "show only failed transactions"..."
autocomplete="off"
v-model="inputValue"
@keydown="handleKeyDown"
:disabled="isLoading"
></textarea>
<button type="submit" class="chat-submit" :disabled="isLoading">â</button>
</form>
</div>
`,
});
import { computed, defineComponent, ref } from "vue";
import { ICellRendererParams } from "ag-grid-community";
export const CountryFlagCellRenderer = defineComponent({
props: {
params: {
type: Object as () => ICellRendererParams,
required: true,
},
},
setup(props) {
const value = ref(props.params.value || "");
const flagUrl = computed(() => {
if (!value.value) return "";
const countryCode = value.value.toLowerCase();
return `https://flags.fmcdn.net/data/flags/mini/${countryCode}.png`;
});
return {
value,
flagUrl,
};
},
template: `
<div v-if="value" style="display: flex; align-items: center; gap: 6px">
<img :src="flagUrl" width="15" height="10" style="border: 0" :alt="value" />
<span>{{ value }}</span>
</div>
`,
});
import { GridApi } from "ag-grid-community";
import { ChatMessage } from "./ChatToolPanel";
import { generateSystemPrompt } from "./systemPrompt";
const CHATGPT_MODEL = "gpt-5-mini";
const BASE_URL = "https://ai-api.ag-grid.com/api/openai/v1";
const AI_API_TOKEN = "";
export const callChatGPT = async (
userRequest: string,
gridApi: GridApi,
conversationHistory: ChatMessage[] = [],
): Promise<any> => {
// Extract relevant parts of the current grid state
const {
aggregation,
rowGroup,
columnSizing,
columnVisibility,
sort,
filter,
pivot,
} = gridApi.getState();
const currentState = {
aggregation,
rowGroup,
columnSizing,
columnVisibility,
sort,
filter,
pivot,
};
// Build LLM Schema from Grid API Structured Schema
const schema = buildLLMSchema(gridApi);
// Build conversation history with system prompt, previous messages, and user request
const messages: ChatMessage[] = [
{ role: "system", content: generateSystemPrompt(currentState) },
...conversationHistory,
{ role: "user", content: userRequest },
];
// Send request to ChatGPT API
let result;
try {
result = await sendRequest({
model: CHATGPT_MODEL,
schema,
messages,
});
} catch (error: any) {
throw new Error(`OpenAI API error: ${error.message || "Unknown error"}`);
}
return result;
};
const buildLLMSchema = (gridApi: GridApi): any => {
// Generate structured schema from grid API
const { $defs, ...structuredSchema } = gridApi.getStructuredSchema({
columns: {
category: {
includeSetValues: true,
},
merchant: {
includeSetValues: true,
},
status: {
includeSetValues: true,
},
currency: {
includeSetValues: true,
},
country: {
includeSetValues: true,
},
accountType: {
includeSetValues: true,
},
type: {
includeSetValues: true,
},
},
});
// Return LLM compatible JSON Schema from AI Toolkit structured schema
return {
type: "object",
$defs,
properties: {
gridState: structuredSchema,
propertiesToIgnore: {
type: "array",
items: {
type: "string",
enum: [
"aggregation",
"filter",
"sort",
"pivot",
"columnVisibility",
"columnSizing",
"rowGroup",
],
},
description:
"List of grid state properties to ignore when applying the new state",
},
explanation: {
type: "string",
description:
"Human-readable explanation of the changes made to the grid state",
},
},
required: ["gridState", "explanation", "propertiesToIgnore"],
additionalProperties: false,
};
};
export const sendRequest = async (options: any): Promise<any> => {
const {
model = "gpt-4o-mini",
schema,
messages,
maxTokens = 4096,
stream = false,
} = options;
const requestBody = {
model,
messages,
max_completion_tokens: maxTokens,
response_format: schema
? {
type: "json_schema",
json_schema: {
name: "grid_state_response",
schema,
},
}
: { type: "json_object" },
stream,
};
const url = `${BASE_URL}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(AI_API_TOKEN ? { Authorization: `Bearer ${AI_API_TOKEN}` } : {}),
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: "Unknown error" }));
const error =
errorData.error?.code === "rate_limit_exceeded"
? "OpenAI Rate Limit Exceeded"
: `OpenAI API error: ${response.status} - ${errorData.error?.message || "Unknown error"}`;
throw new Error(error);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error("No content received from OpenAI API");
}
let parsedObject;
try {
parsedObject = JSON.parse(content);
} catch (error) {
throw new Error(
`Failed to parse JSON response: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
return parsedObject;
};
/**
* Generates an array of mock financial transaction data for testing and demonstration purposes.
*/
export interface ITransaction {
transactionDate: Date;
amount: number;
currency: string;
category: string;
merchant: string;
status: boolean;
country: string;
}
const countries = ["GB", "IE", "FR", "DE", "ES", "NL", "US"];
const countryCurrencyMap: Record<string, string> = {
GB: "GBP",
IE: "EUR",
FR: "EUR",
DE: "EUR",
ES: "EUR",
NL: "EUR",
US: "USD",
};
const statuses: { value: boolean; w: number }[] = [
{ value: true, w: 75 },
{ value: false, w: 25 },
];
const categories: { value: string; w: number; merchants: string[] }[] = [
{
value: "Groceries",
w: 14,
merchants: ["Tesco", "Sainsbury's", "Aldi", "Lidl", "Waitrose"],
},
{ value: "Rent", w: 6, merchants: ["Landlord Ltd", "Lettings Co"] },
{
value: "Utilities",
w: 8,
merchants: ["British Gas", "Octopus Energy", "Thames Water"],
},
{
value: "Dining",
w: 10,
merchants: ["Pret", "Nando's", "PizzaExpress", "Local Cafe"],
},
{
value: "Transport",
w: 10,
merchants: ["TfL", "Uber", "Bolt", "National Rail"],
},
{
value: "Shopping",
w: 12,
merchants: ["Amazon", "John Lewis", "Argos", "ASOS"],
},
{
value: "Travel",
w: 6,
merchants: ["easyJet", "British Airways", "Booking.com", "Trainline"],
},
{ value: "Health", w: 5, merchants: ["Boots", "NHS", "Bupa"] },
{ value: "Salary", w: 6, merchants: ["Acme Corp Payroll", "Globex Payroll"] },
{
value: "Transfers",
w: 8,
merchants: ["Internal Transfer", "External Transfer"],
},
{ value: "Insurance", w: 5, merchants: ["Aviva", "AXA", "Direct Line"] },
{
value: "Entertainment",
w: 10,
merchants: ["Netflix", "Spotify", "Cinema", "Steam"],
},
];
export function generateTransactions({
count = 10000,
seed = 1,
} = {}): ITransaction[] {
// --- seeded RNG (Mulberry32) for repeatable demos ---
function mulberry32(a: number) {
return function () {
let t = (a += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(seed);
const pick = <T,>(arr: T[]): T => arr[Math.floor(rand() * arr.length)];
const weightedPick = <T,>(items: { value: T; w: number }[]): T => {
const total = items.reduce((s, x) => s + x.w, 0);
let r = rand() * total;
for (const it of items) {
r -= it.w;
if (r <= 0) return it.value;
}
return items[items.length - 1].value;
};
// Generate random date between startDate and endDate
const year = new Date().getFullYear() - 1;
const start = new Date(year, 0, 1).getTime(); // Jan 1, previous year
const end = new Date(year, 11, 31).getTime(); // Dec 31, previous year
const randomDate = () => new Date(start + Math.floor(rand() * (end - start)));
// Amount model by category (simple but plausible)
function amountForCategory(cat: string): number {
const round2 = (x: number): number => {
return Math.round(x * 100) / 100;
};
switch (cat) {
case "Rent":
return round2(600 + rand() * 1600);
case "Utilities":
return round2(30 + rand() * 220);
case "Groceries":
return round2(10 + rand() * 180);
case "Dining":
return round2(6 + rand() * 90);
case "Transport":
return round2(2 + rand() * 120);
case "Shopping":
return round2(8 + rand() * 450);
case "Travel":
return round2(30 + rand() * 900);
case "Insurance":
return round2(20 + rand() * 300);
case "Entertainment":
return round2(5 + rand() * 80);
case "Health":
return round2(5 + rand() * 250);
case "Salary":
return round2(1800 + rand() * 3500);
case "Transfers":
return round2(20 + rand() * 2000);
default:
return round2(5 + rand() * 200);
}
}
const rows: ITransaction[] = new Array(count);
for (let i = 0; i < count; i++) {
const catObj = weightedPick(categories.map((c) => ({ value: c, w: c.w })));
const category = catObj.value;
const merchant = pick(catObj.merchants);
const txnDate = randomDate();
const status = weightedPick(statuses);
const country = pick(countries);
const currency = countryCurrencyMap[country];
const magnitude = amountForCategory(category);
const amount = rand() < 0.5 ? -magnitude : magnitude;
rows[i] = {
transactionDate: txnDate,
amount,
currency,
category,
merchant,
status,
country,
};
}
return rows;
}
import { GridOptions, ValueFormatterParams } from "ag-grid-community";
import { ChatToolPanel } from "./ChatToolPanel";
import { CountryFlagCellRenderer } from "./CountryFlagCellRenderer";
import { ITransaction } from "./generateTransactions";
export const gridOptions: GridOptions<ITransaction> = {
columnDefs: [
{
field: "transactionDate",
filter: "agDateColumnFilter",
groupHierarchy: ["formattedMonth"],
enablePivot: true,
enableRowGroup: true,
valueFormatter: (params: ValueFormatterParams) => {
if (params.value == null) return;
return params.value.toLocaleDateString("en-GB", {
year: "numeric",
month: "short",
day: "numeric",
});
},
},
{
field: "country",
filter: "agSetColumnFilter",
cellRenderer: CountryFlagCellRenderer,
enablePivot: true,
enableRowGroup: true,
},
{
field: "status",
filter: "agSetColumnFilter",
enablePivot: true,
enableRowGroup: true,
},
{
field: "amount",
filter: "agNumberColumnFilter",
valueFormatter: (params) => {
if (params.value == null) return;
return params.value.toLocaleString(
`en-${params?.data?.country || "GB"}`,
{
style: "currency",
currency: params.data?.currency || "GBP",
},
);
},
cellStyle: (params) => ({
color: params?.value < 0 ? "#dc3545" : "#28a745",
}),
enableValue: true,
aggFunc: "sum",
},
{
field: "merchant",
filter: "agSetColumnFilter",
enablePivot: true,
enableRowGroup: true,
},
{
field: "category",
filter: "agSetColumnFilter",
enablePivot: true,
enableRowGroup: true,
},
{
field: "currency",
filter: "agSetColumnFilter",
enablePivot: true,
enableRowGroup: true,
hide: true,
},
],
autoSizeStrategy: {
type: "fitCellContents",
},
defaultColDef: {
filter: true,
sortable: true,
resizable: true,
},
pagination: true,
enableFilterHandlers: true,
sideBar: {
toolPanels: [
"columns",
"filters-new",
{
id: "chatPanel",
labelDefault: "AI Assistant",
labelKey: "chatPanel",
iconKey: "message",
toolPanel: ChatToolPanel,
},
],
defaultToolPanel: "chatPanel",
},
icons: {
message:
'<i style="display:inline-flex;line-height:1;vertical-align:middle;color:currentColor;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot-message-square-icon"><path d="M12 6V2H8"/><path d="M15 11v2"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="M20 16a2 2 0 0 1-2 2H8.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 4 20.286V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z"/><path d="M9 11v2"/></svg></i>',
},
};
export const generateSystemPrompt = (state: any) => `
You are an assistant for a table displaying financial transaction data. You help users modify grid configuration to fit their needs.
The data includes transactions with the following fields:
- country: GB, IE, FR, DE, ES, NL, US
- amount: Positive for credits (income), negative for debits (expenses)
- status: True or False indicating if the transaction is cleared
- transactionDate: When the transaction occurred
- category: Groceries, Rent, Utilities, Dining, Transport, Shopping, Travel, Health, Salary, Transfers, Insurance, Entertainment
- merchant: The business or entity involved
- currency: GBP, EUR, or USD
The schema provided can be used to manipulate multiple features of the table to help the user with their query.
Current grid state: ${JSON.stringify(state)}
Respond with only the necessary state changes, not the complete state. Provide a clear explanation of what you changed.
Any unchanged properties that are present in the current state must be included in \`propertiesToIgnore\`. Otherwise they will be removed from the state.
You are not able to make any changes to the grids configuration, e.g. enabling features, you are only able to modify state.
Important: Only modify the properties that the user specifically requested. If they ask to "filter by category", only include filter in your response, not other unrelated properties.
Where possible, augment the provided state `;
API Copy Link
Returns the structured schema of the grid, which includes information about columns, data types, and relationships.
This schema can be passed to AI services to ensure the response is of the correct format. |