From 532ebf3b4469700f70274ceaa9fce5ba2907a7c5 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Wed, 6 May 2026 21:10:52 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20chart=20support=20=E2=80=94=20`make=5Fc?= =?UTF-8?q?hart`=20tool=20with=20recharts=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds inline data visualisation to the chat agent. After a successful run_query, the agent can call make_chart(chart_type, x, y, [group, title, orientation]) and the result is rendered as a bar / line / area / pie chart inline in the chat thread, sourced from the previous query result. Backend (commands/chat.rs, models/chat.rs) - New ChartConfig{chart_type, x, y, group?, title?, orientation?} model. - New AgentAction::MakeChart{config} variant. Parser accepts both `chart_type` and the alternative `type` field name (qwen3 sometimes emits the latter). Validates chart_type is one of bar/line/area/pie. - last_successful_query_result helper finds the most recent successful run_query in the working thread. - MakeChart dispatcher: validates that x/y/group columns exist in the attached query result, emits a tool_result with the same QueryResult in `result` and the chart_config JSON in `text`. Mismatches surface as a clear error ("y column `name` is not in the last result. Available: company_name, legal_name, …"). - build_history compression unchanged: make_chart's tool_result text field (the small chart_config JSON) is included in LLM history; the large QueryResult.rows are NOT, since the per-tool branch only emits text for non-run_query tools. - System prompt: documents make_chart with concrete usage hints (top-N → bar, time series → line/area, proportions → pie; skip for ≤2 or >500 rows). 7 new parser/dispatcher tests. Frontend (src/components/chat/) - recharts ^3.8 added. - New ChartPreview component renders bar (vertical+horizontal), line, area, pie. Supports grouped series via the `group` config field by pivoting rows into a wide format. Y values coerced to numbers (parses strings, nulls → 0). Caps to 500 points to keep things responsive on huge results. - ChatMessageView routes tool=="make_chart" tool_result through a new ChartToolResult that parses the config JSON from the message text and feeds the embedded QueryResult into ChartPreview. - New labels/icons (BarChart3) and preview-extraction for make_chart in tool-call collapsed headers (`bar: carrier_name → trip_count`). Verification: cargo test --lib 77 pass (+7), tsc clean, vitest 20 pass. --- package-lock.json | 373 +++++++++++++++++++++++- package.json | 1 + src-tauri/src/commands/chat.rs | 274 ++++++++++++++++- src-tauri/src/models/chat.rs | 14 + src/components/chat/ChartPreview.tsx | 327 +++++++++++++++++++++ src/components/chat/ChatMessageView.tsx | 59 +++- src/types/index.ts | 11 + 7 files changed, 1054 insertions(+), 5 deletions(-) create mode 100644 src/components/chat/ChartPreview.tsx diff --git a/package-lock.json b/package-lock.json index 780171e..23c0881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-resizable-panels": "^4.6.2", + "recharts": "^3.8.1", "sonner": "^2.0.7", "sql-formatter": "^15.7.0", "tailwind-merge": "^3.4.0", @@ -3652,6 +3653,42 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.7", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.7.tgz", + "integrity": "sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -4033,7 +4070,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@tailwindcss/node": { @@ -4807,6 +4849,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4865,6 +4970,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -6144,6 +6255,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -6193,6 +6425,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", @@ -6492,6 +6730,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -6782,6 +7030,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -7550,6 +7804,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7594,6 +7858,15 @@ "dev": true, "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -9496,10 +9769,32 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -9606,6 +9901,36 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -9620,6 +9945,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9640,6 +9980,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -10339,7 +10685,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -10772,6 +11117,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 740472e..07fd712 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-resizable-panels": "^4.6.2", + "recharts": "^3.8.1", "sonner": "^2.0.7", "sql-formatter": "^15.7.0", "tailwind-merge": "^3.4.0", diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index 2800106..33d5657 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -7,7 +7,7 @@ use crate::commands::memory::{append_memory_core, read_memory_core}; use crate::commands::queries::execute_query_core; use crate::error::{TuskError, TuskResult}; use crate::models::ai::OllamaChatMessage; -use crate::models::chat::{ChatMessage, ChatTurnResult, ContextUsage}; +use crate::models::chat::{ChartConfig, ChatMessage, ChatTurnResult, ContextUsage}; use crate::models::query_result::QueryResult; use crate::state::AppState; use chrono::Utc; @@ -50,6 +50,7 @@ enum AgentAction { Remember { note: String }, SaveQuery { name: String, sql: String }, FindQueries { text: String }, + MakeChart { config: ChartConfig }, } /// Parse the model's JSON response. Accepts both shapes the model tends to emit: @@ -151,6 +152,55 @@ fn parse_agent_action(raw: &str) -> Result { } Ok(AgentAction::FindQueries { text }) } + "make_chart" => { + let chart_type = lookup("chart_type") + .or_else(|| lookup("type")) + .and_then(|v| v.as_str()) + .ok_or_else(|| "make_chart missing `chart_type`".to_string())? + .trim() + .to_lowercase(); + if !["bar", "line", "area", "pie"].contains(&chart_type.as_str()) { + return Err(format!( + "make_chart `chart_type` must be one of: bar, line, area, pie. Got: {}", + chart_type + )); + } + let x = lookup("x") + .and_then(|v| v.as_str()) + .ok_or_else(|| "make_chart missing `x` column".to_string())? + .trim() + .to_string(); + let y = lookup("y") + .and_then(|v| v.as_str()) + .ok_or_else(|| "make_chart missing `y` column".to_string())? + .trim() + .to_string(); + if x.is_empty() || y.is_empty() { + return Err("make_chart `x` and `y` must not be empty".into()); + } + let group = lookup("group") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let title = lookup("title") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let orientation = lookup("orientation") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()); + Ok(AgentAction::MakeChart { + config: ChartConfig { + chart_type, + x, + y, + group, + title, + orientation, + }, + }) + } // Legacy from earlier iterations — silently ignored at parse time so the // model can recover with a different action. "get_schema" => Err( @@ -230,6 +280,9 @@ You operate as an agent in a single-tool-per-turn loop with hop limit {hops}. On {{"action":"save_query","name":"","sql":""}} Persist a non-trivial working SELECT for reuse later. Use AFTER a successful run_query when the query is likely to be re-run. Keep `name` short and descriptive (e.g. "GMV by carrier — last 30d"). The user sees these in sidebar → Saved. + {{"action":"make_chart","chart_type":"bar","x":"","y":"","title":""}} + Visualise the LAST successful run_query result as a chart inline. `chart_type` is one of: bar, line, area, pie. `x` and `y` MUST be column names from the previous result. Optional: `group` (column for series), `orientation` ("vertical"/"horizontal", bar only). Use after run_query when the data is aggregated and would be clearer as a chart (top-N comparisons → bar; time series → line/area; proportions → pie). Skip for tiny results (≤2 rows) and giant ones (>500 rows). + {{"action":"final","text":"..."}} End the turn with a plain-language answer for the user. Do NOT repeat the result table — the UI shows it. Mention caveats (LIMIT, NULL filters, sampling). @@ -671,6 +724,92 @@ pub async fn chat_send( ); push_tool_result(&mut new_messages, &mut working, result); } + AgentAction::MakeChart { config } => { + let config_json = serde_json::to_string(&config).unwrap_or_else(|_| "{}".into()); + push_tool_call( + &mut new_messages, + &mut working, + "make_chart", + config_json.clone(), + ); + + let result_msg = match last_successful_query_result(&working) { + None => ChatMessage::ToolResult { + id: new_id("res"), + tool: "make_chart".to_string(), + is_error: true, + text: Some( + "make_chart needs a successful run_query result above it. Run a SELECT first, then call make_chart." + .to_string(), + ), + result: None, + created_at: now_ms(), + }, + Some(qr) => { + if !qr.columns.iter().any(|c| c == &config.x) { + ChatMessage::ToolResult { + id: new_id("res"), + tool: "make_chart".to_string(), + is_error: true, + text: Some(format!( + "x column `{}` is not in the last result. Available: {}.", + config.x, + qr.columns.join(", ") + )), + result: None, + created_at: now_ms(), + } + } else if !qr.columns.iter().any(|c| c == &config.y) { + ChatMessage::ToolResult { + id: new_id("res"), + tool: "make_chart".to_string(), + is_error: true, + text: Some(format!( + "y column `{}` is not in the last result. Available: {}.", + config.y, + qr.columns.join(", ") + )), + result: None, + created_at: now_ms(), + } + } else if let Some(group) = &config.group { + if !qr.columns.iter().any(|c| c == group) { + ChatMessage::ToolResult { + id: new_id("res"), + tool: "make_chart".to_string(), + is_error: true, + text: Some(format!( + "group column `{}` is not in the last result. Available: {}.", + group, + qr.columns.join(", ") + )), + result: None, + created_at: now_ms(), + } + } else { + ChatMessage::ToolResult { + id: new_id("res"), + tool: "make_chart".to_string(), + is_error: false, + text: Some(config_json.clone()), + result: Some(qr), + created_at: now_ms(), + } + } + } else { + ChatMessage::ToolResult { + id: new_id("res"), + tool: "make_chart".to_string(), + is_error: false, + text: Some(config_json.clone()), + result: Some(qr), + created_at: now_ms(), + } + } + } + }; + push_tool_result(&mut new_messages, &mut working, result_msg); + } } // Any non-RunQuery, non-Final action means the model is investigating @@ -825,6 +964,26 @@ fn format_db_error(e: &TuskError) -> String { e.to_string() } +/// Locate the most recent SUCCESSFUL run_query in the working thread and +/// return its full QueryResult. Used by make_chart to attach data to a chart +/// directive without relying on the model to re-send it. +fn last_successful_query_result(messages: &[ChatMessage]) -> Option { + for m in messages.iter().rev() { + if let ChatMessage::ToolResult { + tool, + is_error: false, + result: Some(qr), + .. + } = m + { + if tool == "run_query" { + return Some(qr.clone()); + } + } + } + None +} + /// Pull the most recent run_query error text from the working thread, so the /// post-loop "I gave up" summary can quote concrete errors back to the user. fn last_run_query_error(messages: &[ChatMessage]) -> Option { @@ -1307,6 +1466,119 @@ mod tests { assert!(last_run_query_error(&msgs).is_none()); } + #[test] + fn parses_make_chart_minimal() { + let a = parse_agent_action( + r#"{"action":"make_chart","chart_type":"bar","x":"carrier","y":"trips"}"#, + ) + .unwrap(); + match a { + AgentAction::MakeChart { config } => { + assert_eq!(config.chart_type, "bar"); + assert_eq!(config.x, "carrier"); + assert_eq!(config.y, "trips"); + assert!(config.group.is_none()); + assert!(config.title.is_none()); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn parses_make_chart_with_group_and_title() { + let a = parse_agent_action( + r#"{"action":"make_chart","chart_type":"line","x":"month","y":"revenue","group":"region","title":"Revenue"}"#, + ) + .unwrap(); + match a { + AgentAction::MakeChart { config } => { + assert_eq!(config.group.as_deref(), Some("region")); + assert_eq!(config.title.as_deref(), Some("Revenue")); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn make_chart_accepts_alternative_field_name_type() { + // Some models emit `type` instead of `chart_type`. + let a = parse_agent_action( + r#"{"action":"make_chart","type":"pie","x":"label","y":"value"}"#, + ) + .unwrap(); + match a { + AgentAction::MakeChart { config } => assert_eq!(config.chart_type, "pie"), + _ => panic!("wrong variant"), + } + } + + #[test] + fn rejects_make_chart_with_unknown_chart_type() { + let r = parse_agent_action( + r#"{"action":"make_chart","chart_type":"radar","x":"a","y":"b"}"#, + ); + assert!(r.is_err()); + } + + #[test] + fn rejects_make_chart_missing_x_or_y() { + assert!(parse_agent_action(r#"{"action":"make_chart","chart_type":"bar","y":"a"}"#).is_err()); + assert!(parse_agent_action(r#"{"action":"make_chart","chart_type":"bar","x":"a"}"#).is_err()); + } + + #[test] + fn last_successful_query_result_finds_recent() { + use crate::models::query_result::QueryResult; + let qr = QueryResult { + columns: vec!["a".into()], + types: vec!["INT4".into()], + rows: vec![vec![Value::Number(1.into())]], + row_count: 1, + execution_time_ms: 1, + }; + let msgs = vec![ + ChatMessage::ToolResult { + id: "r1".into(), + tool: "run_query".into(), + is_error: false, + text: None, + result: Some(qr.clone()), + created_at: 1, + }, + ChatMessage::ToolResult { + id: "r2".into(), + tool: "run_query".into(), + is_error: true, + text: Some("oops".into()), + result: None, + created_at: 2, + }, + ]; + let found = last_successful_query_result(&msgs).expect("ok"); + assert_eq!(found.columns, vec!["a".to_string()]); + } + + #[test] + fn last_successful_query_result_skips_non_run_query() { + use crate::models::query_result::QueryResult; + let qr = QueryResult { + columns: vec!["a".into()], + types: vec!["INT4".into()], + rows: vec![], + row_count: 0, + execution_time_ms: 0, + }; + let msgs = vec![ChatMessage::ToolResult { + id: "r1".into(), + tool: "list_tables".into(), + is_error: false, + text: Some("public.x".into()), + result: Some(qr), + created_at: 1, + }]; + assert!(last_successful_query_result(&msgs).is_none()); + } + #[test] fn render_thread_for_summary_includes_roles_and_skips_rows() { let msgs = vec![ diff --git a/src-tauri/src/models/chat.rs b/src-tauri/src/models/chat.rs index fbde600..2893b50 100644 --- a/src-tauri/src/models/chat.rs +++ b/src-tauri/src/models/chat.rs @@ -32,3 +32,17 @@ pub struct ChatTurnResult { pub usage: ContextUsage, } +/// Chart configuration produced by the agent's `make_chart` tool. +/// Embedded as JSON in `ToolResult.text` for tool == "make_chart" while the +/// underlying data lives in `ToolResult.result`. The frontend reads both to +/// render the chart inline. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChartConfig { + pub chart_type: String, // "bar" | "line" | "area" | "pie" + pub x: String, // column name for X axis / category + pub y: String, // column name for Y axis / numeric value + pub group: Option, // optional column for series grouping + pub title: Option, + pub orientation: Option, // "vertical" | "horizontal" — bar only +} + diff --git a/src/components/chat/ChartPreview.tsx b/src/components/chat/ChartPreview.tsx new file mode 100644 index 0000000..f079d48 --- /dev/null +++ b/src/components/chat/ChartPreview.tsx @@ -0,0 +1,327 @@ +import { useMemo } from "react"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { ChartConfig } from "@/types"; + +interface Props { + config: ChartConfig; + columns: string[]; + rows: unknown[][]; + height?: number; +} + +const PALETTE = [ + "#60a5fa", // blue-400 + "#34d399", // emerald-400 + "#fbbf24", // amber-400 + "#f87171", // red-400 + "#a78bfa", // violet-400 + "#22d3ee", // cyan-400 + "#fb923c", // orange-400 + "#f472b6", // pink-400 +]; + +const MAX_POINTS = 500; + +export function ChartPreview({ config, columns, rows, height = 280 }: Props) { + const xIdx = columns.indexOf(config.x); + const yIdx = columns.indexOf(config.y); + const groupIdx = config.group ? columns.indexOf(config.group) : -1; + + const limited = useMemo(() => rows.slice(0, MAX_POINTS), [rows]); + + if (xIdx < 0 || yIdx < 0) { + return ( + + ); + } + + // Coerce y values to numbers; chart libs need numeric Y. + const numericY = (v: unknown): number => { + if (typeof v === "number") return v; + if (typeof v === "string") { + const n = parseFloat(v); + return Number.isFinite(n) ? n : 0; + } + return 0; + }; + + const labelX = (v: unknown): string => { + if (v == null) return "—"; + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return JSON.stringify(v); + }; + + const isGrouped = groupIdx >= 0; + + // ──────────── grouped data shape ──────────── + // For multi-series: pivot to { x: , : yVal, : yVal, … } + // Used by line, area, and grouped-bar. + const pivoted = useMemo(() => { + if (!isGrouped) return null; + const map = new Map>(); + const groupSet = new Set(); + for (const row of limited) { + const xv = labelX(row[xIdx]); + const gv = labelX(row[groupIdx!]); + const yv = numericY(row[yIdx]); + groupSet.add(gv); + const acc = map.get(xv) ?? { _x: xv }; + acc[gv] = ((acc[gv] as number) ?? 0) + yv; + map.set(xv, acc); + } + return { + data: Array.from(map.values()), + groups: Array.from(groupSet), + }; + }, [isGrouped, limited, xIdx, yIdx, groupIdx]); + + // Single series shape: [{ _x, _y }] + const flat = useMemo(() => { + return limited.map((row) => ({ + _x: labelX(row[xIdx]), + _y: numericY(row[yIdx]), + })); + }, [limited, xIdx, yIdx]); + + const tickStyle = { + fill: "var(--muted-foreground)", + fontSize: 10, + } as const; + + const axisLine = { + stroke: "rgba(255, 255, 255, 0.08)", + } as const; + + const tooltipStyle = { + backgroundColor: "var(--popover)", + border: "1px solid var(--border)", + borderRadius: 6, + fontSize: 11, + } as const; + + if (config.chart_type === "pie") { + // Pie: aggregate y by x label (sum), no group support. + const agg = new Map(); + for (const row of limited) { + const xv = labelX(row[xIdx]); + agg.set(xv, (agg.get(xv) ?? 0) + numericY(row[yIdx])); + } + const data = Array.from(agg.entries()).map(([name, value]) => ({ name, value })); + return ( + + + + + typeof entry.name === "string" && entry.name.length < 20 ? entry.name : "" + } + > + {data.map((_, i) => ( + + ))} + + + + + + + ); + } + + if (config.chart_type === "line") { + return ( + + + + + + + + {isGrouped ? ( + <> + + {pivoted!.groups.map((g, i) => ( + + ))} + + ) : ( + + )} + + + + ); + } + + if (config.chart_type === "area") { + return ( + + + + + + + + {isGrouped ? ( + <> + + {pivoted!.groups.map((g, i) => ( + + ))} + + ) : ( + + )} + + + + ); + } + + // bar (default) + const horizontal = config.orientation === "horizontal"; + return ( + + + + + {horizontal ? ( + <> + + + + ) : ( + <> + + + + )} + + {isGrouped ? ( + <> + + {pivoted!.groups.map((g, i) => ( + + ))} + + ) : ( + + )} + + + + ); +} + +function ChartFrame({ + config, + height, + count, + totalRows, + children, +}: { + config: ChartConfig; + height: number; + count: number; + totalRows: number; + children: React.ReactNode; +}) { + return ( +
+
+ + {config.title ?? `${capitalize(config.chart_type)} chart`} + + + {count} point{count === 1 ? "" : "s"} + {totalRows > MAX_POINTS && ` (of ${totalRows}, capped at ${MAX_POINTS})`} + +
+
+ {children} +
+
+ ); +} + +function ChartFallback({ config, message }: { config: ChartConfig; message: string }) { + return ( +
+
+ Chart {config.chart_type} failed +
+
{message}
+
+ ); +} + +function capitalize(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/src/components/chat/ChatMessageView.tsx b/src/components/chat/ChatMessageView.tsx index 58b266d..8db7693 100644 --- a/src/components/chat/ChatMessageView.tsx +++ b/src/components/chat/ChatMessageView.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { ResultsTable } from "@/components/results/ResultsTable"; import { ExportDialog } from "@/components/export/ExportDialog"; +import { ChartPreview } from "./ChartPreview"; import { Dialog, DialogContent, @@ -24,8 +25,9 @@ import { BookmarkPlus, Maximize2, Download, + BarChart3, } from "lucide-react"; -import type { ChatMessage } from "@/types"; +import type { ChartConfig, ChatMessage } from "@/types"; interface Props { message: ChatMessage; @@ -161,6 +163,11 @@ function ToolResultBlock({ return ; } + // make_chart — render chart inline using config from text + data from result. + if (tool === "make_chart") { + return ; + } + // run_query — full results table with Open-full / Export actions. if (result) { return ; @@ -169,6 +176,45 @@ function ToolResultBlock({ return null; } +function ChartToolResult({ + text, + result, +}: { + text: string | null; + result: { columns: string[]; types: string[]; rows: unknown[][]; row_count: number; execution_time_ms: number } | null; +}) { + let config: ChartConfig | null = null; + try { + if (text) { + config = JSON.parse(text) as ChartConfig; + } + } catch { + config = null; + } + if (!config || !result) { + return ( +
+ +
+
Chart unavailable
+
+ The agent referenced a chart but the previous query result is not attached. +
+
+
+ ); + } + return ( +
+ +
+ ); +} + function RunQueryResultBlock({ result, }: { @@ -318,6 +364,8 @@ function labelForTool(tool: string): string { return "Save query"; case "find_queries": return "Find saved queries"; + case "make_chart": + return "Make chart"; case "get_schema": return "Load schema"; default: @@ -343,6 +391,8 @@ function iconForTool(tool: string) { return BookmarkPlus; case "find_queries": return Bookmark; + case "make_chart": + return BarChart3; case "get_schema": return Database; default: @@ -368,6 +418,13 @@ function extractToolPreview(tool: string, inputJson: string): string | null { return typeof parsed.name === "string" ? parsed.name : null; case "find_queries": return typeof parsed.text === "string" ? parsed.text : null; + case "make_chart": { + const t = typeof parsed.chart_type === "string" ? parsed.chart_type : null; + const x = typeof parsed.x === "string" ? parsed.x : null; + const y = typeof parsed.y === "string" ? parsed.y : null; + if (t && x && y) return `${t}: ${x} → ${y}`; + return null; + } default: return null; } diff --git a/src/types/index.ts b/src/types/index.ts index d3da46d..b21fd1c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -215,3 +215,14 @@ export interface ChatTurnResult { messages: ChatMessage[]; usage: ContextUsage; } + +export type ChartType = "bar" | "line" | "area" | "pie"; + +export interface ChartConfig { + chart_type: ChartType; + x: string; + y: string; + group?: string | null; + title?: string | null; + orientation?: string | null; +}