diff --git a/package-lock.json b/package-lock.json index b5013b8..471e357 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6230,6 +6230,12 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -6839,9 +6845,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", "dev": true, "funding": [ { @@ -6859,10 +6865,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.27.0", - "caniuse-lite": "^1.0.30001754", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", - "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, @@ -7175,9 +7180,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", - "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7245,9 +7250,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -7265,11 +7270,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -7406,9 +7411,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001756", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", - "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", "dev": true, "funding": [ { @@ -9998,10 +10003,26 @@ "dev": true, "license": "MIT" }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/electron-to-chromium": { - "version": "1.5.258", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz", - "integrity": "sha512-rHUggNV5jKQ0sSdWwlaRDkFc3/rRJIVnOSe9yR4zrR07m3ZxhP4N27Hlg8VeJGGYgFTxK5NqDmWI4DSH72vIJg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -11039,6 +11060,79 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -11059,6 +11153,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -11167,6 +11274,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -12942,7 +13065,6 @@ "version": "0.16.27", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", - "dev": true, "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -12959,7 +13081,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -13749,6 +13870,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -14124,6 +14264,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -14811,16 +14970,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -15852,6 +16001,25 @@ "regjsparser": "bin/parser" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", @@ -15885,6 +16053,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -17399,6 +17583,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -17425,6 +17623,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -17503,9 +17715,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -18244,6 +18456,21 @@ "zod": "^3.25 || ^4" } }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -18302,18 +18529,22 @@ "license": "GPL-3.0", "dependencies": { "cli-highlight": "^2.1.11", + "echarts": "^5.6.0", "fastembed": "^2.0.0", "ink": "^6.4.0", "ink-big-text": "^2.0.0", "ink-gradient": "^3.0.0", "ink-select-input": "^6.2.0", "ink-text-input": "^6.0.0", + "katex": "^0.16.27", "mic": "^2.1.2", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "sharp": "^0.34.3", "ws": "^8.18.0" }, @@ -18344,6 +18575,7 @@ "@types/uuid": "^10.0.0", "@types/wav": "^1.0.4", "@types/ws": "^8.5.13", + "autoprefixer": "^10.4.23", "babel-jest": "^29.7.0", "diff": "^8.0.2", "esbuild": "^0.26.0", @@ -18361,7 +18593,9 @@ "node-html-markdown": "^1.3.0", "node-poppler": "^8.0.4", "pdf-parse": "^1.1.1", + "postcss": "^8.5.6", "react": "^19.2.0", + "rimraf": "^6.1.2", "ts-jest": "^29.2.5", "tsx": "^4.20.6", "typescript": "^5.9.3", diff --git a/packages/cletus/docs/chart-display.md b/packages/cletus/docs/chart-display.md new file mode 100644 index 0000000..ac6e741 --- /dev/null +++ b/packages/cletus/docs/chart-display.md @@ -0,0 +1,171 @@ +# Chart Display Operation + +The `chart_display` operation allows you to visualize data as interactive charts in the browser UI using ECharts. + +## Usage + +The operation is browser-only and displays charts with the ability to switch between different visualization variants. + +### Basic Example + +```typescript +{ + "chartGroup": "partToWhole", + "title": "Market Share by Company", + "data": [ + { "name": "Apple", "value": 28 }, + { "name": "Samsung", "value": 22 }, + { "name": "Xiaomi", "value": 13 }, + { "name": "Oppo", "value": 10 }, + { "name": "Others", "value": 27 } + ], + "defaultVariant": "pie" +} +``` + +## Chart Groups and Variants + +Each chart group represents a category of related visualizations that display similar types of data: + +### Part to Whole (`partToWhole`) +Shows how individual parts contribute to a whole. +- **Variants**: `pie`, `donut`, `treemap`, `sunburst` + +### Category Comparison (`categoryComparison`) +Compares values across different categories. +- **Variants**: `bar`, `horizontalBar`, `pictorialBar` + +### Time Series (`timeSeries`) +Displays data trends over time. +- **Variants**: `line`, `area`, `step`, `smoothLine` + +### Distribution (`distribution`) +Shows how data is distributed. +- **Variants**: `histogram`, `boxplot` + +### Correlation (`correlation`) +Shows relationships between variables. +- **Variants**: `scatter`, `effectScatter`, `heatmap` + +### Ranking (`ranking`) +Displays ordered/ranked data. +- **Variants**: `orderedBar`, `horizontalOrderedBar` + +### Hierarchical (`hierarchical`) +Represents hierarchical data structures. +- **Variants**: `treemap`, `sunburst`, `tree` + +### Flow (`flow`) +Shows flow or funnel processes. +- **Variants**: `sankey`, `funnel` + +### Geospatial (`geospatial`) +Displays geographic data. +- **Variants**: `map` + +### Multivariate Comparison (`multivariateComparison`) +Compares multiple variables simultaneously. +- **Variants**: `groupedBar`, `stackedBar`, `radar`, `parallel` + +## Data Format + +Data should be an array of objects with `name` and `value` properties: + +```json +[ + { "name": "Category A", "value": 10 }, + { "name": "Category B", "value": 20 }, + { "name": "Category C", "value": 30 } +] +``` + +## Advanced Usage + +### Custom Variant Options + +You can customize each variant with ECharts-specific options: + +```typescript +{ + "chartGroup": "partToWhole", + "data": [...], + "variantOptions": { + "pie": { + "series": [{ + "label": { + "show": true, + "position": "outside" + } + }] + }, + "donut": { + "series": [{ + "label": { + "show": false + } + }] + } + } +} +``` + +## Browser UI + +In the browser, the chart will be displayed with: +1. A visual representation of the data +2. Variant selector buttons above the chart +3. Click any variant button to instantly switch the chart type +4. All variants in the same chart group display the same underlying data + +## Example Use Cases + +### Sales Data +```typescript +{ + "chartGroup": "categoryComparison", + "title": "Monthly Sales", + "data": [ + { "name": "Jan", "value": 1200 }, + { "name": "Feb", "value": 1500 }, + { "name": "Mar", "value": 1800 } + ], + "defaultVariant": "bar" +} +``` + +### Time Series Analysis +```typescript +{ + "chartGroup": "timeSeries", + "title": "Temperature Over Time", + "data": [ + { "name": "00:00", "value": 20 }, + { "name": "06:00", "value": 18 }, + { "name": "12:00", "value": 25 }, + { "name": "18:00", "value": 22 } + ], + "defaultVariant": "smoothLine" +} +``` + +### Budget Breakdown +```typescript +{ + "chartGroup": "partToWhole", + "title": "Budget Allocation", + "data": [ + { "name": "Engineering", "value": 50000 }, + { "name": "Marketing", "value": 30000 }, + { "name": "Sales", "value": 25000 }, + { "name": "Operations", "value": 20000 } + ], + "defaultVariant": "treemap" +} +``` + +## Implementation Details + +- **Mode**: `local` (no approval needed) +- **Toolset**: Artist +- **Browser Only**: Yes (requires browser UI to display) +- **Library**: ECharts 5.x diff --git a/packages/cletus/esbuild.browser.cjs b/packages/cletus/esbuild.browser.cjs index 310e66b..d6ddff8 100644 --- a/packages/cletus/esbuild.browser.cjs +++ b/packages/cletus/esbuild.browser.cjs @@ -28,6 +28,21 @@ async function build() { fs.writeFileSync(path.join(distDir, 'styles.css'), result.css); console.log('✓ Tailwind CSS built successfully'); + // Copy KaTeX CSS + console.log('Copying KaTeX CSS...'); + // Try to resolve katex CSS from node_modules (works with monorepo structure) + let katexCssPath = path.join(__dirname, 'node_modules/katex/dist/katex.min.css'); + if (!fs.existsSync(katexCssPath)) { + // Try root node_modules for monorepo + katexCssPath = path.join(__dirname, '../../node_modules/katex/dist/katex.min.css'); + } + if (fs.existsSync(katexCssPath)) { + fs.copyFileSync(katexCssPath, path.join(distDir, 'katex.min.css')); + console.log('✓ KaTeX CSS copied successfully'); + } else { + console.warn('⚠ KaTeX CSS not found, skipping...'); + } + // Build the browser client console.log('Building browser client...'); await esbuild.build({ @@ -52,10 +67,10 @@ async function build() { 'utf-8' ); - // Inject the CSS link into the HTML with absolute path + // Inject the CSS links into the HTML with absolute paths const updatedHtml = htmlContent.replace( '', - ' \n' + ' \n \n' ); fs.writeFileSync( diff --git a/packages/cletus/package.json b/packages/cletus/package.json index 7c82e18..81dcf75 100644 --- a/packages/cletus/package.json +++ b/packages/cletus/package.json @@ -41,18 +41,22 @@ ], "dependencies": { "cli-highlight": "^2.1.11", + "echarts": "^5.6.0", "fastembed": "^2.0.0", "ink": "^6.4.0", "ink-big-text": "^2.0.0", "ink-gradient": "^3.0.0", "ink-select-input": "^6.2.0", "ink-text-input": "^6.0.0", + "katex": "^0.16.27", "mic": "^2.1.2", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "sharp": "^0.34.3", "ws": "^8.18.0" }, @@ -83,6 +87,7 @@ "@types/uuid": "^10.0.0", "@types/wav": "^1.0.4", "@types/ws": "^8.5.13", + "autoprefixer": "^10.4.23", "babel-jest": "^29.7.0", "diff": "^8.0.2", "esbuild": "^0.26.0", @@ -100,7 +105,9 @@ "node-html-markdown": "^1.3.0", "node-poppler": "^8.0.4", "pdf-parse": "^1.1.1", + "postcss": "^8.5.6", "react": "^19.2.0", + "rimraf": "^6.1.2", "ts-jest": "^29.2.5", "tsx": "^4.20.6", "typescript": "^5.9.3", diff --git a/packages/cletus/src/agents/chat-agent.ts b/packages/cletus/src/agents/chat-agent.ts index 5dc2595..d206fb4 100644 --- a/packages/cletus/src/agents/chat-agent.ts +++ b/packages/cletus/src/agents/chat-agent.ts @@ -153,7 +153,6 @@ export function createChatAgent(ai: CletusAI) { continue; // Skip utility, it's always available } - // All toolsets including DBA const tools = toolRegistry.getToolset(name); const toolSignatures = tools .map(t => { diff --git a/packages/cletus/src/browser/components/ChartViewer.tsx b/packages/cletus/src/browser/components/ChartViewer.tsx new file mode 100644 index 0000000..d67a79b --- /dev/null +++ b/packages/cletus/src/browser/components/ChartViewer.tsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { X, Download, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; +import { cn } from '../lib/utils'; +import * as echarts from 'echarts'; +import type { EChartsOption } from 'echarts'; + +interface ChartViewerProps { + option: EChartsOption; + isOpen: boolean; + onClose: () => void; + availableVariants?: string[]; + currentVariant?: string; + onVariantChange?: (variant: string) => void; +} + +export const ChartViewer: React.FC = ({ option, isOpen, onClose, availableVariants, currentVariant, onVariantChange }) => { + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + + // Initialize chart when opened + useEffect(() => { + if (isOpen && chartRef.current && !chartInstanceRef.current) { + const chart = echarts.init(chartRef.current); + chartInstanceRef.current = chart; + chart.setOption(option); + + // Handle resize + const handleResize = () => chart.resize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chart.dispose(); + chartInstanceRef.current = null; + }; + } + }, [isOpen, option]); + + // Update chart when option changes + useEffect(() => { + if (chartInstanceRef.current && isOpen) { + chartInstanceRef.current.setOption(option, true); + } + }, [option, isOpen]); + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + // Download chart as PNG + const downloadChart = () => { + if (!chartInstanceRef.current) return; + + try { + const url = chartInstanceRef.current.getDataURL({ + type: 'png', + pixelRatio: 2, + backgroundColor: '#000', + }); + const a = document.createElement('a'); + a.href = url; + a.download = `chart-${Date.now()}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (error) { + console.error('Failed to download chart:', error); + } + }; + + if (!isOpen) return null; + + return ( +
{ + if (e.target === e.currentTarget) { + onClose(); + } + }} + > + {/* Variant Toggle */} + {availableVariants && availableVariants.length > 1 && currentVariant && onVariantChange && ( +
+ Type: +
+ {availableVariants.map((variant) => ( + + ))} +
+
+ )} + + {/* Controls */} +
+ + +
+ + {/* Chart */} +
+
+
+ + {/* Instructions */} +
+ ESC or click outside to close +
+
+ ); +}; + +interface ClickableChartProps { + option: EChartsOption; + className?: string; + style?: React.CSSProperties; + availableVariants?: string[]; + currentVariant?: string; + onVariantChange?: (variant: string) => void; +} + +export const ClickableChart: React.FC = ({ option, className, style, availableVariants, currentVariant, onVariantChange }) => { + const [isViewerOpen, setIsViewerOpen] = useState(false); + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + + // Initialize chart + useEffect(() => { + if (!chartRef.current) return; + + const chart = echarts.init(chartRef.current); + chartInstanceRef.current = chart; + chart.setOption(option); + + // Handle resize + const handleResize = () => chart.resize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chart.dispose(); + }; + }, []); + + // Update chart when option changes + useEffect(() => { + if (chartInstanceRef.current) { + chartInstanceRef.current.setOption(option, true); + } + }, [option]); + + return ( + <> +
+
+ {/* Fullscreen button overlay */} + +
+ setIsViewerOpen(false)} + availableVariants={availableVariants} + currentVariant={currentVariant} + onVariantChange={onVariantChange} + /> + + ); +}; diff --git a/packages/cletus/src/browser/components/DiagramViewer.tsx b/packages/cletus/src/browser/components/DiagramViewer.tsx index 779b43e..6f0e716 100644 --- a/packages/cletus/src/browser/components/DiagramViewer.tsx +++ b/packages/cletus/src/browser/components/DiagramViewer.tsx @@ -178,7 +178,7 @@ export const DiagramViewer: React.FC = ({ spec, isOpen, onCl
1 ? 'cursor-grab' : 'cursor-default' )} onWheel={handleWheel} diff --git a/packages/cletus/src/browser/components/MessageItem.tsx b/packages/cletus/src/browser/components/MessageItem.tsx index 904f73b..4c75373 100644 --- a/packages/cletus/src/browser/components/MessageItem.tsx +++ b/packages/cletus/src/browser/components/MessageItem.tsx @@ -1,12 +1,15 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { User, Bot, Info } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; import { OperationDisplay } from '../operations'; import { TypingIndicator } from './TypingIndicator'; import { cn } from '../lib/utils'; import { ClickableImage } from './ImageViewer'; import type { Message } from '../../schemas'; +import type { Components } from 'react-markdown'; interface MessageItemProps { message: Message; @@ -15,8 +18,68 @@ interface MessageItemProps { onApproveOperation: (message: Message, idx: number) => void; onRejectOperation: (message: Message, idx: number) => void; hasMultiplePendingOps?: boolean; + isProcessing?: boolean; } +// Preprocess content to convert LaTeX delimiters to markdown math delimiters +const preprocessLatex = (text: string): string => { + // Convert \[...\] to $$...$$ (display math) + text = text.replace(/\\\[([\s\S]*?)\\\]/g, (match, content) => `$$${content}$$`); + // Convert \(...\) to $...$ (inline math) + text = text.replace(/\\\(([\s\S]*?)\\\)/g, (match, content) => `$${content}$`); + // Convert standalone [... ] patterns that look like display math (with array/equation content) + // Only convert if it contains LaTeX commands like \begin, \text, etc. + text = text.replace(/\[\s*(\\begin|\\text|\\frac|\\int|\\sum|\\prod)([\s\S]*?)\s*\]/g, (match, cmd, rest) => `$$${cmd}${rest}$$`); + return text; +}; + +// Stable markdown components reference - created once and reused +const markdownComponents: Components = { + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + code: ({ inline, children, ...props }: any) => { + return inline ? ( + + {children} + + ) : ( + + {children} + + ); + }, + pre: ({ children }) =>
    {children}
    , + a: ({ href, children }) => ( + + {children} + + ), + img: ({ src, alt, ...props }: any) => { + // Transform local file paths to use the /file route + const imageSrc = src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') + ? `/file?path=${encodeURIComponent(src)}` + : src; + return ; + }, +}; + +// Memoized markdown content renderer +const MarkdownContent = React.memo<{ content: string }>(({ content }) => { + const processedContent = useMemo(() => preprocessLatex(content), [content]); + + return ( + + {processedContent} + + ); +}); + export const MessageItem: React.FC = ({ message, operationDecisions, @@ -24,6 +87,7 @@ export const MessageItem: React.FC = ({ onApproveOperation, onRejectOperation, hasMultiplePendingOps = false, + isProcessing = false, }) => { const { role, name, content, operations = [] } = message; @@ -79,11 +143,11 @@ export const MessageItem: React.FC = ({ {/* Content and Operations in order */}
    - {visibleContent.length === 0 && isAssistant ? ( + {visibleContent.length === 0 && isAssistant && isProcessing ? (
    - ) : ( + ) : visibleContent.length === 0 && isAssistant ? null : ( visibleContent.map((item, index) => { // Render operation if this content item has an operation if (item.operation && item.operationIndex !== undefined) { @@ -121,41 +185,7 @@ export const MessageItem: React.FC = ({ isSystem && 'bg-muted/50 border border-muted italic' )} > -

    {children}

    , - ul: ({ children }) =>
      {children}
    , - ol: ({ children }) =>
      {children}
    , - li: ({ children }) =>
  • {children}
  • , - code: ({ inline, children, ...props }: any) => { - return inline ? ( - - {children} - - ) : ( - - {children} - - ); - }, - pre: ({ children }) =>
    {children}
    , - a: ({ href, children }) => ( - - {children} - - ), - img: ({ src, alt, ...props }: any) => { - // Transform local file paths to use the /file route - const imageSrc = src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') - ? `/file?path=${encodeURIComponent(src)}` - : src; - return ; - }, - }} - > - {item.content} -
    +
    ); }) diff --git a/packages/cletus/src/browser/components/MessageList.tsx b/packages/cletus/src/browser/components/MessageList.tsx index 28eeb2a..bc066fc 100644 --- a/packages/cletus/src/browser/components/MessageList.tsx +++ b/packages/cletus/src/browser/components/MessageList.tsx @@ -44,7 +44,7 @@ export const MessageList: React.FC = ({
    {messages.map((message, index) => { // Check if this is the last message with pending operations - // const isLastMessage = index === messages.length - 1; + const isLastMessage = index === messages.length - 1; // const hasPendingOps = message.operations?.some(op => op.status === 'analyzed'); const pendingOpCount = message.operations?.filter(op => op.status === 'analyzed' || op.status === 'doing').length || 0; @@ -57,6 +57,7 @@ export const MessageList: React.FC = ({ onApproveOperation={onApproveOperation} onRejectOperation={onRejectOperation} hasMultiplePendingOps={pendingOpCount > 1} + isProcessing={isLastMessage && loading} /> ); })} diff --git a/packages/cletus/src/browser/components/OperationDisplay.tsx b/packages/cletus/src/browser/components/OperationDisplay.tsx index f49dcf1..ac84dea 100644 --- a/packages/cletus/src/browser/components/OperationDisplay.tsx +++ b/packages/cletus/src/browser/components/OperationDisplay.tsx @@ -370,6 +370,7 @@ export interface OperationDisplayProps { operation: Operation; label: string; summary?: React.ReactNode | string | null; + summaryClasses?: string; borderColor?: string; bgColor?: string; labelColor?: string; @@ -390,6 +391,7 @@ export const OperationDisplay: React.FC = ({ label, summary, borderColor = 'border-border', + summaryClasses = 'max-h-[8rem] overflow-y-auto', bgColor = 'bg-card/50', labelColor = 'text-foreground', message, @@ -425,7 +427,7 @@ export const OperationDisplay: React.FC = ({ {/* Summary */} {displaySummary && ( -
    +
    {typeof displaySummary === 'string' ? ( <> diff --git a/packages/cletus/src/browser/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index e1c40bb..cc44644 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -1,8 +1,13 @@ -import React from 'react'; -import { abbreviate, pluralize } from '../../shared'; -import { createRenderer } from './render'; +import * as echarts from 'echarts'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { abbreviate, deepMerge, pluralize } from '../../shared'; import { ClickableImage } from '../components/ImageViewer'; +import { createRenderer } from './render'; +import { ChartDataPoint, ChartVariant } from '../../helpers/artist'; +import { type EChartsOption } from 'echarts'; import { ClickableDiagram } from '../components/DiagramViewer'; +import { ClickableChart } from '../components/ChartViewer'; + const renderer = createRenderer({ borderColor: "border-neon-pink/30", @@ -36,7 +41,8 @@ export const image_generate = renderer<'image_generate'>( return `Generated ${pluralize(count, 'image')}`; } return null; - } + }, + () => ({ summaryClasses: '' }) ); export const image_edit = renderer<'image_edit'>( @@ -59,7 +65,8 @@ export const image_edit = renderer<'image_edit'>( return 'Edited image saved'; } return null; - } + }, + () => ({ summaryClasses: '' }) ); export const image_analyze = renderer<'image_analyze'>( @@ -109,6 +116,30 @@ export const image_attach = renderer<'image_attach'>( } ); +// ============================================================================ +// Chart Display Renderer +// ============================================================================ + +export const chart_display = renderer<'chart_display'>( + (op) => `ChartDisplay(${op.input.chart.chartGroup}, ${op.input.chart.data?.length || 0} points)`, + (op): string | React.ReactNode | null => { + if (op.output) { + return ( + + ); + } + return null; + }, + () => ({ summaryClasses: '' }) +); + export const diagram_show = renderer<'diagram_show'>( (op) => `DiagramShow()`, (op): string | React.ReactNode | null => { @@ -124,5 +155,277 @@ export const diagram_show = renderer<'diagram_show'>( ); } return null; - } + }, + () => ({ summaryClasses: '' }) ); + + +// ============================================================================ +// Chart Display Component +// ============================================================================ + +const ChartDisplay: React.FC<{ + chartGroup: string; + availableVariants: ChartVariant[]; + currentVariant: ChartVariant; + option: EChartsOption; + data: ChartDataPoint[]; + variantOptions: Partial>>; +}> = ({ chartGroup, availableVariants, currentVariant: initialVariant, option: initialOption, data, variantOptions }) => { + const [currentVariant, setCurrentVariant] = useState(initialVariant); + + // Extract global options (title, etc.) from initial option to preserve across variants + const globalOptions = useMemo>(() => ({ + title: initialOption.title, + grid: initialOption.grid, + backgroundColor: initialOption.backgroundColor, + }), [initialOption.title, initialOption.grid, initialOption.backgroundColor]); + + // Compute current option based on selected variant + const currentOption = useMemo(() => { + const variantSpecificOption = buildOptionForVariant(currentVariant, data, variantOptions[currentVariant] || {}); + // Merge global options (title, etc.) with variant-specific options + return deepMerge(variantSpecificOption, globalOptions); + }, [currentVariant, data, variantOptions, globalOptions]); + + const handleVariantChange = (variant: ChartVariant) => { + setCurrentVariant(variant); + }; + + return ( +
    +
    + Type: +
    + {availableVariants.map((variant) => ( + + ))} +
    +
    + +
    + ); +}; + +/** + * Build ECharts option for a specific variant + * + * Note: This function is duplicated from operations/artist.tsx because: + * 1. The browser code cannot import from Node.js-specific files + * 2. Extracting to shared.ts would require moving all chart logic there + * 3. The logic needs to be in sync for server-side and client-side rendering + * + * If making changes here, ensure the same changes are made in operations/artist.tsx + */ +function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], variantOption: Partial): EChartsOption { + // Dark mode axis styling + const axisStyle = { + axisLine: { lineStyle: { color: '#666' } }, + axisLabel: { color: '#ffffff' }, + splitLine: { lineStyle: { color: '#333' } }, + }; + + const baseOption: EChartsOption = { + backgroundColor: 'transparent', + textStyle: { + color: '#ffffff', + }, + tooltip: { + trigger: 'item', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + borderColor: '#666', + textStyle: { + color: '#ffffff', + }, + }, + legend: { + textStyle: { + color: '#ffffff', + }, + }, + series: [], + }; + + // Apply variant-specific series configuration + switch (variant) { + case 'pie': + baseOption.series = [{ + type: 'pie', + radius: '50%', + data, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)', + }, + }, + }]; + break; + + case 'donut': + baseOption.series = [{ + type: 'pie', + radius: ['40%', '70%'], + data, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)', + }, + }, + }]; + break; + + case 'treemap': + baseOption.series = [{ + type: 'treemap', + data, + }]; + break; + + case 'sunburst': + baseOption.series = [{ + type: 'sunburst', + data, + radius: [0, '90%'], + }]; + break; + + case 'bar': + baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle }; + baseOption.yAxis = { type: 'value', ...axisStyle }; + baseOption.series = [{ + type: 'bar', + data: data.map((d: any) => d.value), + }]; + break; + + case 'horizontalBar': + baseOption.yAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle }; + baseOption.xAxis = { type: 'value', ...axisStyle }; + baseOption.series = [{ + type: 'bar', + data: data.map((d: any) => d.value), + }]; + break; + + case 'pictorialBar': + baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle }; + baseOption.yAxis = { type: 'value', ...axisStyle }; + baseOption.series = [{ + type: 'pictorialBar', + data: data.map((d: any) => d.value), + symbol: 'rect', + }]; + break; + + case 'line': + baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle }; + baseOption.yAxis = { type: 'value', ...axisStyle }; + baseOption.series = [{ + type: 'line', + data: data.map((d: any) => d.value), + }]; + break; + + case 'area': + baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle }; + baseOption.yAxis = { type: 'value', ...axisStyle }; + baseOption.series = [{ + type: 'line', + data: data.map((d: any) => d.value), + areaStyle: {}, + }]; + break; + + case 'step': + baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle }; + baseOption.yAxis = { type: 'value', ...axisStyle }; + baseOption.series = [{ + type: 'line', + data: data.map((d: any) => d.value), + step: 'start', + }]; + break; + + case 'smoothLine': + baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle }; + baseOption.yAxis = { type: 'value', ...axisStyle }; + baseOption.series = [{ + type: 'line', + data: data.map((d: any) => d.value), + smooth: true, + }]; + break; + + case 'histogram': + case 'boxplot': + case 'scatter': + case 'effectScatter': + case 'heatmap': + case 'tree': + case 'sankey': + case 'funnel': + case 'map': + case 'radar': + case 'parallel': + // Use the variant name directly as ECharts type (these are already correct) + baseOption.series = [{ + type: variant, + data, + }]; + break; + + case 'orderedBar': + case 'horizontalOrderedBar': + // Ordered bars are just bars with sorted data + const sortedData = [...data].sort((a: any, b: any) => b.value - a.value); + if (variant === 'horizontalOrderedBar') { + baseOption.yAxis = { type: 'category', data: sortedData.map((d: any) => d.name), ...axisStyle }; + baseOption.xAxis = { type: 'value', ...axisStyle }; + } else { + baseOption.xAxis = { type: 'category', data: sortedData.map((d: any) => d.name), ...axisStyle }; + baseOption.yAxis = { type: 'value', ...axisStyle }; + } + baseOption.series = [{ + type: 'bar', + data: sortedData.map((d: any) => d.value), + }]; + break; + + case 'groupedBar': + case 'stackedBar': + // Grouped and stacked bars need multiple series + // For now, treat as regular bar - requires more complex data structure + baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle }; + baseOption.yAxis = { type: 'value', ...axisStyle }; + baseOption.series = [{ + type: 'bar', + data: data.map((d: any) => d.value), + stack: variant === 'stackedBar' ? 'total' : undefined, + }]; + break; + } + + // Deep merge variant-specific options + return deepMerge(baseOption, variantOption); +} diff --git a/packages/cletus/src/browser/pages/MainPage.tsx b/packages/cletus/src/browser/pages/MainPage.tsx index 56d93f4..06a1c35 100644 --- a/packages/cletus/src/browser/pages/MainPage.tsx +++ b/packages/cletus/src/browser/pages/MainPage.tsx @@ -38,6 +38,8 @@ export const MainPage: React.FC = ({ config }) => { const [showProfile, setShowProfile] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [clearConfirmText, setClearConfirmText] = useState(''); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); const [isEditingTitle, setIsEditingTitle] = useState(false); const [editedTitle, setEditedTitle] = useState(''); const [totalCost, setTotalCost] = useState(0); @@ -292,6 +294,7 @@ export const MainPage: React.FC = ({ config }) => { setPendingMessage(null); setTemporaryUserMessage(null); setTemporaryAssistantMessage(null); + setChatMetaState(null); // Clear cached chat metadata when switching chats setStatus(''); // Clear any previous error or status messages setLoading(true); window.history.pushState({}, '', `/chat/${chatId}`); @@ -460,7 +463,7 @@ export const MainPage: React.FC = ({ config }) => { return; } send({ type: 'update_chat_meta', data: { chatId: selectedChatId, updates: { title: editedTitle.trim() } } }); - setIsEditingTitle(false); + handleCancelEditTitle(); }; const handleCancelEditTitle = () => { @@ -470,12 +473,17 @@ export const MainPage: React.FC = ({ config }) => { const handleDeleteChat = () => { if (!selectedChatId || !chatMeta) return; - if (confirm(`Are you sure you want to delete "${chatMeta.title}"? This cannot be undone.`)) { - send({ type: 'delete_chat', data: { chatId: selectedChatId } }); - setSelectedChatId(null); - setMessages([]); - setTimeout(() => send({ type: 'get_config' }), 500); - } + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = () => { + if (!selectedChatId || deleteConfirmText !== 'DELETE') return; + send({ type: 'delete_chat', data: { chatId: selectedChatId } }); + setSelectedChatId(null); + setMessages([]); + setShowDeleteConfirm(false); + setDeleteConfirmText(''); + setTimeout(() => send({ type: 'get_config' }), 500); }; const handleOperationApproval = (message: Message, approved: number[], rejected: number[]) => { @@ -905,6 +913,63 @@ export const MainPage: React.FC = ({ config }) => {
    )} + + {showDeleteConfirm && chatMeta && ( +
    { + setShowDeleteConfirm(false); + setDeleteConfirmText(''); + }} + > +
    e.stopPropagation()} + > +

    Delete Chat

    +

    + Are you sure you want to delete "{chatMeta.title}"? This will permanently delete all messages in this chat. This action cannot be undone. +

    +

    + Type DELETE to confirm: +

    + setDeleteConfirmText(e.target.value)} + placeholder="Type DELETE" + className="mb-4 font-mono" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleConfirmDelete(); + } else if (e.key === 'Escape') { + setShowDeleteConfirm(false); + setDeleteConfirmText(''); + } + }} + /> +
    + + +
    +
    +
    + )}
    ); }; diff --git a/packages/cletus/src/common.ts b/packages/cletus/src/common.ts index b5a87c5..fb94943 100644 --- a/packages/cletus/src/common.ts +++ b/packages/cletus/src/common.ts @@ -23,6 +23,8 @@ export { normalizeNewlines, convertNewlines, paginateText, + deepMerge, + isObject, type NewlineType, } from './shared'; diff --git a/packages/cletus/src/helpers/artist.ts b/packages/cletus/src/helpers/artist.ts new file mode 100644 index 0000000..fb92d77 --- /dev/null +++ b/packages/cletus/src/helpers/artist.ts @@ -0,0 +1,207 @@ + +import { EChartsOption } from 'echarts'; +import { z } from 'zod'; + +// ============================================================================ +// Chart Display Schemas +// ============================================================================ + +export const ChartDataPointSchema = z.object({ + name: z.string().describe('Name/label for the data point'), + value: z.number().describe('Numeric value for the data point'), +}); + +export type ChartDataPoint = z.infer; + +export const PartToWholeChartSchema = z.object({ + chartGroup: z.literal('partToWhole'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of data points showing parts of a whole'), + variantOptions: z.object({ + pie: z.record(z.string(), z.any()).optional(), + donut: z.record(z.string(), z.any()).optional(), + treemap: z.record(z.string(), z.any()).optional(), + sunburst: z.record(z.string(), z.any()).optional(), + }).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['pie', 'donut', 'treemap', 'sunburst']).optional().describe('Default variant to display'), +}); + +export const CategoryComparisonChartSchema = z.object({ + chartGroup: z.literal('categoryComparison'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of data points for category comparison'), + variantOptions: z.record( + z.enum(['bar', 'horizontalBar', 'pictorialBar']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['bar', 'horizontalBar', 'pictorialBar']).optional().describe('Default variant to display'), +}); + +export const TimeSeriesChartSchema = z.object({ + chartGroup: z.literal('timeSeries'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of time-series data points'), + variantOptions: z.record( + z.enum(['line', 'area', 'step', 'smoothLine']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['line', 'area', 'step', 'smoothLine']).optional().describe('Default variant to display'), +}); + +export const DistributionChartSchema = z.object({ + chartGroup: z.literal('distribution'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of data points for distribution analysis'), + variantOptions: z.record( + z.enum(['histogram', 'boxplot']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['histogram', 'boxplot']).optional().describe('Default variant to display'), +}); + +export const CorrelationChartSchema = z.object({ + chartGroup: z.literal('correlation'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of data points for correlation analysis'), + variantOptions: z.record( + z.enum(['scatter', 'effectScatter', 'heatmap']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['scatter', 'effectScatter', 'heatmap']).optional().describe('Default variant to display'), +}); + +export const RankingChartSchema = z.object({ + chartGroup: z.literal('ranking'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of data points to rank'), + variantOptions: z.record( + z.enum(['orderedBar', 'horizontalOrderedBar']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['orderedBar', 'horizontalOrderedBar']).optional().describe('Default variant to display'), +}); + +export const HierarchicalChartSchema = z.object({ + chartGroup: z.literal('hierarchical'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of hierarchical data points'), + variantOptions: z.record( + z.enum(['treemap', 'sunburst', 'tree']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['treemap', 'sunburst', 'tree']).optional().describe('Default variant to display'), +}); + +export const FlowChartSchema = z.object({ + chartGroup: z.literal('flow'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of flow/funnel data points'), + variantOptions: z.record( + z.enum(['sankey', 'funnel']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['sankey', 'funnel']).optional().describe('Default variant to display'), +}); + +export const GeospatialChartSchema = z.object({ + chartGroup: z.literal('geospatial'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of geographic data points'), + variantOptions: z.record( + z.enum(['map']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['map']).optional().describe('Default variant to display'), +}); + +export const MultivariateComparisonChartSchema = z.object({ + chartGroup: z.literal('multivariateComparison'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(ChartDataPointSchema).describe('Array of data points for multivariate comparison'), + variantOptions: z.record( + z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']), + z.record(z.string(), z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']).optional().describe('Default variant to display'), +}); + +// Union of all chart schemas +export const ChartConfigSchema = z.union([ + PartToWholeChartSchema, + CategoryComparisonChartSchema, + TimeSeriesChartSchema, + DistributionChartSchema, + CorrelationChartSchema, + RankingChartSchema, + HierarchicalChartSchema, + FlowChartSchema, + GeospatialChartSchema, + MultivariateComparisonChartSchema, +]); + +export type ChartConfig = z.infer; + + +// ============================================================================ +// Chart Display Types +// ============================================================================ + +export type ChartGroup = + | 'partToWhole' + | 'categoryComparison' + | 'timeSeries' + | 'distribution' + | 'correlation' + | 'ranking' + | 'hierarchical' + | 'flow' + | 'geospatial' + | 'multivariateComparison'; + +export type ChartVariant = + // partToWhole + | 'pie' | 'donut' | 'treemap' | 'sunburst' + // categoryComparison + | 'bar' | 'horizontalBar' | 'pictorialBar' + // timeSeries + | 'line' | 'area' | 'step' | 'smoothLine' + // distribution + | 'histogram' | 'boxplot' + // correlation + | 'scatter' | 'effectScatter' | 'heatmap' + // ranking + | 'orderedBar' | 'horizontalOrderedBar' + // hierarchical (overlaps with partToWhole) + | 'tree' + // flow + | 'sankey' | 'funnel' + // geospatial + | 'map' + // multivariateComparison + | 'groupedBar' | 'stackedBar' | 'radar' | 'parallel'; + +export const ChartGroupVariants: Record = { + partToWhole: ['pie', 'donut', 'treemap', 'sunburst'], + categoryComparison: ['bar', 'horizontalBar', 'pictorialBar'], + timeSeries: ['line', 'area', 'step', 'smoothLine'], + distribution: ['histogram', 'boxplot'], + correlation: ['scatter', 'effectScatter', 'heatmap'], + ranking: ['orderedBar', 'horizontalOrderedBar'], + hierarchical: ['treemap', 'sunburst', 'tree'], + flow: ['sankey', 'funnel'], + geospatial: ['map'], // Removed scatter to avoid duplication with correlation group + multivariateComparison: ['groupedBar', 'stackedBar', 'radar', 'parallel'], +}; + +export type ChartDisplayInput = { + chart: ChartConfig; +}; + +export type ChartDisplayOutput = { + chartGroup: ChartGroup; + availableVariants: ChartVariant[]; + currentVariant: ChartVariant; + option: EChartsOption; + data: ChartDataPoint[]; + variantOptions: Partial>>; +}; \ No newline at end of file diff --git a/packages/cletus/src/operations/__tests__/chart-display.test.ts b/packages/cletus/src/operations/__tests__/chart-display.test.ts new file mode 100644 index 0000000..b3fcd4e --- /dev/null +++ b/packages/cletus/src/operations/__tests__/chart-display.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from '@jest/globals'; +import { chart_display } from '../artist'; + +describe('chart_display operation', () => { + it('should create a chart with partToWhole group', async () => { + const input = { + chartGroup: 'partToWhole' as const, + title: 'Test Chart', + data: [ + { name: 'A', value: 10 }, + { name: 'B', value: 20 }, + { name: 'C', value: 30 }, + ], + defaultVariant: 'pie' as const, + }; + + const output = await chart_display.do({ input } as any, {} as any); + + expect(output.chartGroup).toBe('partToWhole'); + expect(output.currentVariant).toBe('pie'); + expect(output.availableVariants).toEqual(['pie', 'donut', 'treemap', 'sunburst']); + expect(output.data).toEqual(input.data); + expect(output.option).toBeDefined(); + expect(output.option.series).toBeDefined(); + expect(output.option.series.length).toBeGreaterThan(0); + }); + + it('should create a chart with categoryComparison group', async () => { + const input = { + chartGroup: 'categoryComparison' as const, + data: [ + { name: 'X', value: 15 }, + { name: 'Y', value: 25 }, + ], + }; + + const output = await chart_display.do({ input } as any, {} as any); + + expect(output.chartGroup).toBe('categoryComparison'); + expect(output.currentVariant).toBe('bar'); + expect(output.availableVariants).toEqual(['bar', 'horizontalBar', 'pictorialBar']); + }); + + it('should use default variant when not specified', async () => { + const input = { + chartGroup: 'timeSeries' as const, + data: [ + { name: '2023', value: 100 }, + { name: '2024', value: 150 }, + ], + }; + + const output = await chart_display.do({ input } as any, {} as any); + + expect(output.currentVariant).toBe('line'); // First variant in timeSeries group + }); + + it('should include title in option when provided', async () => { + const input = { + chartGroup: 'ranking' as const, + title: 'Top Items', + data: [ + { name: 'Item 1', value: 50 }, + { name: 'Item 2', value: 30 }, + ], + }; + + const output = await chart_display.do({ input } as any, {} as any); + + expect(output.option.title).toBeDefined(); + expect(output.option.title.text).toBe('Top Items'); + }); + + it('should merge variant options', async () => { + const input = { + chartGroup: 'partToWhole' as const, + data: [ + { name: 'A', value: 10 }, + ], + defaultVariant: 'pie' as const, + variantOptions: { + pie: { + series: [{ + label: { + show: true, + position: 'outside', + }, + }], + }, + }, + }; + + const output = await chart_display.do({ input } as any, {} as any); + + expect(output.variantOptions.pie).toBeDefined(); + }); +}); diff --git a/packages/cletus/src/operations/artist.tsx b/packages/cletus/src/operations/artist.tsx index 5d55b1d..aea49c6 100644 --- a/packages/cletus/src/operations/artist.tsx +++ b/packages/cletus/src/operations/artist.tsx @@ -2,12 +2,15 @@ import { ImageGenerationResponse } from "@aeye/ai"; import fs from 'fs/promises'; import path from 'path'; import url from 'url'; -import { abbreviate, cosineSimilarity, linkFile, paginateText, pluralize } from "../common"; +import { abbreviate, cosineSimilarity, deepMerge, isObject, linkFile, paginateText, pluralize } from "../common"; import { canEmbed, embed } from "../embed"; import { getImagePath } from "../file-manager"; import { fileIsReadable, searchFiles } from "../helpers/files"; import { renderOperation } from "../helpers/render"; import { operationOf } from "./types"; +import { ChartDataPoint, ChartDisplayInput, ChartDisplayOutput, ChartGroupVariants, ChartVariant } from "../helpers/artist"; +import type { EChartsOption } from "echarts"; + function resolveImage(cwd: string, imagePath: string): string { const [_, _filename, filepath] = imagePath.match(/^\[([^\]]+)\]\(([^)]+)\)$/) || []; @@ -480,6 +483,60 @@ export const image_attach = operationOf< ), }); +export const chart_display = operationOf< + ChartDisplayInput, + ChartDisplayOutput +>({ + mode: 'local', + signature: 'chart_display(chart)', + status: (input) => `Displaying ${input.chart.chartGroup} chart`, + analyze: async ({ input }) => { + // Local operation, no analysis needed + return { + analysis: `This will display a ${input.chart.chartGroup} chart with ${input.chart.data?.length || 0} data points`, + doable: true, + }; + }, + do: async ({ input }) => { + const { chartGroup, data, title, variantOptions, defaultVariant } = input.chart; + const availableVariants = ChartGroupVariants[chartGroup]; + const currentVariant = defaultVariant || availableVariants[0]; + const variantOpts = (variantOptions || {}) as Partial>>; + + // Build base option from input + const baseOption: EChartsOption = { + title: title ? { text: title, left: 'center' } : undefined, + tooltip: { trigger: 'item' }, + legend: {}, + series: [], + }; + + // Apply variant-specific options + const variantOption = variantOpts[currentVariant] || {}; + const option = applyVariantToOption(baseOption, currentVariant, data, variantOption); + + return { + chartGroup, + availableVariants, + currentVariant, + option, + data, + variantOptions: variantOpts, + }; + }, + render: (op, ai, showInput, showOutput) => renderOperation( + op, + `ChartDisplay(${op.input.chart.chartGroup}, ${op.input.chart.data?.length || 0} points)`, + (op) => { + if (op.output) { + return `Displaying ${op.output.chartGroup} chart as ${op.output.currentVariant}`; + } + return null; + }, + showInput, showOutput + ), +}); + export const diagram_show = operationOf< { spec: string }, { spec: string } @@ -514,3 +571,188 @@ export const diagram_show = operationOf< ), }); +/** + * Apply variant-specific transformations to the base option + * + * Note: This function is duplicated in browser/operations/artist.tsx as buildOptionForVariant because: + * 1. The browser code cannot import from Node.js-specific files + * 2. Extracting to shared.ts would require moving all chart logic there + * 3. The logic needs to be in sync for server-side and client-side rendering + * + * If making changes here, ensure the same changes are made in browser/operations/artist.tsx + */ +function applyVariantToOption( + baseOption: EChartsOption, + variant: ChartVariant, + data: ChartDataPoint[], + variantOption: Partial +): EChartsOption { + const option = { ...baseOption }; + + // Apply variant-specific series configuration + switch (variant) { + case 'pie': + option.series = [{ + type: 'pie', + radius: '50%', + data, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)', + }, + }, + }]; + break; + + case 'donut': + option.series = [{ + type: 'pie', + radius: ['40%', '70%'], + data, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)', + }, + }, + }]; + break; + + case 'treemap': + option.series = [{ + type: 'treemap', + data, + }]; + break; + + case 'sunburst': + option.series = [{ + type: 'sunburst', + data, + radius: [0, '90%'], + }]; + break; + + case 'bar': + option.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; + option.yAxis = { type: 'value' }; + option.series = [{ + type: 'bar', + data: data.map((d: any) => d.value), + }]; + break; + + case 'horizontalBar': + option.yAxis = { type: 'category', data: data.map((d: any) => d.name) }; + option.xAxis = { type: 'value' }; + option.series = [{ + type: 'bar', + data: data.map((d: any) => d.value), + }]; + break; + + case 'pictorialBar': + option.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; + option.yAxis = { type: 'value' }; + option.series = [{ + type: 'pictorialBar', + data: data.map((d: any) => d.value), + symbol: 'rect', + }]; + break; + + case 'line': + option.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; + option.yAxis = { type: 'value' }; + option.series = [{ + type: 'line', + data: data.map((d: any) => d.value), + }]; + break; + + case 'area': + option.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; + option.yAxis = { type: 'value' }; + option.series = [{ + type: 'line', + data: data.map((d: any) => d.value), + areaStyle: {}, + }]; + break; + + case 'step': + option.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; + option.yAxis = { type: 'value' }; + option.series = [{ + type: 'line', + data: data.map((d: any) => d.value), + step: 'start', + }]; + break; + + case 'smoothLine': + option.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; + option.yAxis = { type: 'value' }; + option.series = [{ + type: 'line', + data: data.map((d: any) => d.value), + smooth: true, + }]; + break; + + case 'histogram': + case 'boxplot': + case 'scatter': + case 'effectScatter': + case 'heatmap': + case 'tree': + case 'sankey': + case 'funnel': + case 'map': + case 'radar': + case 'parallel': + // Use the variant name directly as ECharts type (these are already correct) + option.series = [{ + type: variant as any, + data, + }]; + break; + + case 'orderedBar': + case 'horizontalOrderedBar': + // Ordered bars are just bars with sorted data + const sortedData = [...data].sort((a: any, b: any) => b.value - a.value); + if (variant === 'horizontalOrderedBar') { + option.yAxis = { type: 'category', data: sortedData.map((d: any) => d.name) }; + option.xAxis = { type: 'value' }; + } else { + option.xAxis = { type: 'category', data: sortedData.map((d: any) => d.name) }; + option.yAxis = { type: 'value' }; + } + option.series = [{ + type: 'bar', + data: sortedData.map((d: any) => d.value), + }]; + break; + + case 'groupedBar': + case 'stackedBar': + // Grouped and stacked bars need multiple series + // For now, treat as regular bar - requires more complex data structure + option.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; + option.yAxis = { type: 'value' }; + option.series = [{ + type: 'bar', + data: data.map((d: any) => d.value), + stack: variant === 'stackedBar' ? 'total' : undefined, + }]; + break; + } + + // Merge variant-specific options + return deepMerge(option, variantOption); +} + diff --git a/packages/cletus/src/schemas.ts b/packages/cletus/src/schemas.ts index 1db8d58..73c0123 100644 --- a/packages/cletus/src/schemas.ts +++ b/packages/cletus/src/schemas.ts @@ -280,6 +280,7 @@ export const OperationKindSchema = z.enum([ 'image_describe', 'image_find', 'image_attach', + 'chart_display', 'diagram_show', // clerk 'file_search', diff --git a/packages/cletus/src/shared.ts b/packages/cletus/src/shared.ts index a07f1f4..c4a9295 100644 --- a/packages/cletus/src/shared.ts +++ b/packages/cletus/src/shared.ts @@ -368,3 +368,43 @@ export function paginateText( return text.slice(start, end); } } + +/** + * Deep merge two objects. This is useful for combining options objects. + * Arrays are replaced, not merged. + * + * @param target - target object + * @param source - source object to merge into target + * @returns merged object + */ +export function deepMerge(target: any, source: any): T { + if (!source) return target; + + const output = { ...target }; + + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach(key => { + if (isObject(source[key])) { + if (!(key in target)) { + output[key] = source[key]; + } else { + output[key] = deepMerge(target[key], source[key]); + } + } else { + output[key] = source[key]; + } + }); + } + + return output; +} + +/** + * Check if a value is a plain object (not an array, not null, not a function). + * + * @param item - value to check + * @returns true if the value is a plain object + */ +export function isObject(item: any): boolean { + return item && typeof item === 'object' && !Array.isArray(item); +} diff --git a/packages/cletus/src/tools/ABOUT.md b/packages/cletus/src/tools/ABOUT.md index c0af15f..0db0736 100644 --- a/packages/cletus/src/tools/ABOUT.md +++ b/packages/cletus/src/tools/ABOUT.md @@ -17,7 +17,7 @@ Cletus is a sophisticated AI assistant that lives in your terminal. It combines: Cletus organizes its capabilities through specialized toolsets: - **Architect** - Manages custom data type definitions -- **Artist** - Handles image generation, editing, analysis, and search +- **Artist** - Handles image generation, editing, analysis, and search (browser client has charts and diagrams) - **Clerk** - Performs file operations, searching, and indexing - **DBA** - Database-like operations on typed data with full CRUD support - **Internet** - Web search, page scraping, and REST API calls diff --git a/packages/cletus/src/tools/artist.ts b/packages/cletus/src/tools/artist.ts index 4286140..5a429cb 100644 --- a/packages/cletus/src/tools/artist.ts +++ b/packages/cletus/src/tools/artist.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { globalToolProperties, type CletusAI } from '../ai'; import { getOperationInput } from '../operations/types'; +import { ChartConfigSchema } from '../helpers/artist'; /** * Create artist tools for image operations @@ -116,6 +117,44 @@ Example: Attach an image file: call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx), }); + // Chart display schemas - moved outside function and exported above + + const chartDisplay = ai.tool({ + name: 'chart_display', + description: 'Display data as an interactive chart in the browser UI', + instructions: `Use this to visualize data as a chart. This is browser-only and will display an interactive chart with variant switching capabilities. + +Chart groups and their available variants: +- partToWhole: pie, donut, treemap, sunburst (for showing parts of a whole) +- categoryComparison: bar, horizontalBar, pictorialBar (for comparing categories) +- timeSeries: line, area, step, smoothLine (for data over time) +- distribution: histogram, boxplot (for data distribution) +- correlation: scatter, effectScatter, heatmap (for showing relationships) +- ranking: orderedBar, horizontalOrderedBar (for ranked data) +- hierarchical: treemap, sunburst, tree (for hierarchical data) +- flow: sankey, funnel (for flow/process data) +- geospatial: map (for geographic data) +- multivariateComparison: groupedBar, stackedBar, radar, parallel (for comparing multiple variables) + +Data format: Provide an array of objects with 'name' and 'value' properties, e.g.: +[{ "name": "Apple", "value": 28 }, { "name": "Samsung", "value": 22 }] + +The chart will be displayed in the browser with controls to switch between different variants of the same chart group. + +Example: Display market share as a pie chart: +{ "chartGroup": "partToWhole", "title": "Market Share", "data": [{"name": "Apple", "value": 28}, {"name": "Samsung", "value": 22}], "defaultVariant": "pie" } + +{{modeInstructions}}`, + schema: z.object({ + chart: ChartConfigSchema, + ...globalToolProperties, + }), + strict: false, + metadata: { onlyClient: 'browser' }, + input: getOperationInput('chart_display'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'chart_display', input }, ctx), + }); + const diagramShow = ai.tool({ name: 'diagram_show', description: 'Display a Mermaid diagram in the chat (browser only)', @@ -141,6 +180,7 @@ Example: Show a flowchart: imageDescribe, imageFind, imageAttach, + chartDisplay, diagramShow, ] as [ typeof imageGenerate, @@ -149,6 +189,7 @@ Example: Show a flowchart: typeof imageDescribe, typeof imageFind, typeof imageAttach, + typeof chartDisplay, typeof diagramShow, ]; }