From ef10ca4e6bc384b49780f5195d9176202803a49b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 03:45:13 +0000 Subject: [PATCH 01/12] Initial plan From 4a724b404911a935b45fc75c635f9d60e94417cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 03:54:04 +0000 Subject: [PATCH 02/12] Add chart_display operation with echarts integration Co-authored-by: ClickerMonkey <421233+ClickerMonkey@users.noreply.github.com> --- package-lock.json | 112 +++++-- packages/cletus/package.json | 5 + .../cletus/src/browser/operations/artist.tsx | 282 ++++++++++++++++ packages/cletus/src/operations/artist.tsx | 309 ++++++++++++++++++ packages/cletus/src/schemas.ts | 1 + packages/cletus/src/tools/artist.ts | 51 +++ 6 files changed, 725 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 29ac7f7..3380b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5739,6 +5739,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/echarts": { + "version": "4.9.22", + "resolved": "https://registry.npmjs.org/@types/echarts/-/echarts-4.9.22.tgz", + "integrity": "sha512-7Fo6XdWpoi8jxkwP7BARUOM7riq8bMhmsCtSG8gzUcJmFhLo387tihoBYS/y5j7jl3PENT5RxeWZdN9RiwO7HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/zrender": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5966,6 +5976,13 @@ "@types/node": "*" } }, + "node_modules/@types/zrender": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/zrender/-/zrender-4.0.6.tgz", + "integrity": "sha512-1jZ9bJn2BsfmYFPBHtl5o3uV+ILejAtGrDcYSpT4qaVKEI/0YY+arw3XHU04Ebd8Nca3SQ7uNcLaqiL+tTFVMg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -6419,9 +6436,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": [ { @@ -6439,10 +6456,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" }, @@ -6755,9 +6771,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": { @@ -6825,9 +6841,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": [ { @@ -6845,11 +6861,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" @@ -6986,9 +7002,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": [ { @@ -8948,10 +8964,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" }, @@ -13598,16 +13630,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", @@ -16171,9 +16193,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": [ { @@ -16857,6 +16879,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", @@ -16915,6 +16952,7 @@ "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", @@ -16948,6 +16986,7 @@ "@tailwindcss/typography": "^0.5.19", "@tavily/core": "^0.5.13", "@types/diff": "^7.0.2", + "@types/echarts": "^4.9.22", "@types/file-type": "^10.6.0", "@types/jest": "^29.5.14", "@types/node": "^24.9.1", @@ -16957,6 +16996,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", @@ -16973,7 +17013,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/package.json b/packages/cletus/package.json index 31a804e..477dadd 100644 --- a/packages/cletus/package.json +++ b/packages/cletus/package.json @@ -41,6 +41,7 @@ ], "dependencies": { "cli-highlight": "^2.1.11", + "echarts": "^5.6.0", "fastembed": "^2.0.0", "ink": "^6.4.0", "ink-big-text": "^2.0.0", @@ -74,6 +75,7 @@ "@tailwindcss/typography": "^0.5.19", "@tavily/core": "^0.5.13", "@types/diff": "^7.0.2", + "@types/echarts": "^4.9.22", "@types/file-type": "^10.6.0", "@types/jest": "^29.5.14", "@types/node": "^24.9.1", @@ -83,6 +85,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", @@ -99,7 +102,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/browser/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index 2330b38..f97718f 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { abbreviate, pluralize } from '../../shared'; import { createRenderer } from './render'; import { ClickableImage } from '../components/ImageViewer'; +import * as echarts from 'echarts'; +import type { ChartVariant } from '../../operations/artist'; const renderer = createRenderer({ borderColor: "border-neon-pink/30", @@ -107,3 +109,283 @@ export const image_attach = renderer<'image_attach'>( return null; } ); + +// ============================================================================ +// Chart Display Component +// ============================================================================ + +const ChartDisplay: React.FC<{ + chartGroup: string; + availableVariants: ChartVariant[]; + currentVariant: ChartVariant; + option: any; + data: any[]; + variantOptions: any; +}> = ({ chartGroup, availableVariants, currentVariant: initialVariant, option: initialOption, data, variantOptions }) => { + const chartRef = React.useRef(null); + const chartInstanceRef = React.useRef(null); + const [currentVariant, setCurrentVariant] = React.useState(initialVariant); + + // Initialize chart + React.useEffect(() => { + if (!chartRef.current) return; + + const chart = echarts.init(chartRef.current); + chartInstanceRef.current = chart; + + // Set initial option + chart.setOption(initialOption); + + // Handle resize + const handleResize = () => chart.resize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chart.dispose(); + }; + }, []); + + // Update chart when variant changes + React.useEffect(() => { + if (!chartInstanceRef.current) return; + + const newOption = buildOptionForVariant(currentVariant, data, variantOptions[currentVariant] || {}); + chartInstanceRef.current.setOption(newOption, true); + }, [currentVariant, data, variantOptions]); + + const handleVariantChange = (variant: ChartVariant) => { + setCurrentVariant(variant); + }; + + return ( +
+
+ Variant: +
+ {availableVariants.map((variant) => ( + + ))} +
+
+
+
+ ); +}; + +/** + * Build ECharts option for a specific variant + */ +function buildOptionForVariant(variant: ChartVariant, data: any[], variantOption: any): any { + const baseOption: any = { + tooltip: { trigger: 'item' }, + legend: {}, + 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) }; + baseOption.yAxis = { type: 'value' }; + 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) }; + baseOption.xAxis = { type: 'value' }; + 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) }; + baseOption.yAxis = { type: 'value' }; + 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) }; + baseOption.yAxis = { type: 'value' }; + 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) }; + baseOption.yAxis = { type: 'value' }; + 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) }; + baseOption.yAxis = { type: 'value' }; + 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) }; + baseOption.yAxis = { type: 'value' }; + 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 'orderedBar': + case 'horizontalOrderedBar': + case 'tree': + case 'sankey': + case 'funnel': + case 'map': + case 'groupedBar': + case 'stackedBar': + case 'radar': + case 'parallel': + // For now, use sensible defaults for other chart types + baseOption.series = [{ + type: variant.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''), + data, + }]; + break; + } + + // Deep merge variant-specific options + return deepMerge(baseOption, variantOption); +} + +/** + * Deep merge two objects + */ +function deepMerge(target: any, source: any): any { + 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; +} + +function isObject(item: any): boolean { + return item && typeof item === 'object' && !Array.isArray(item); +} + +// ============================================================================ +// Chart Display Renderer +// ============================================================================ + +export const chart_display = renderer<'chart_display'>( + (op) => `ChartDisplay(${op.input.chartGroup}, ${op.input.data?.length || 0} points)`, + (op): string | React.ReactNode | null => { + if (op.output) { + return ( + + ); + } + return null; + } +); diff --git a/packages/cletus/src/operations/artist.tsx b/packages/cletus/src/operations/artist.tsx index 41049f3..48d24d8 100644 --- a/packages/cletus/src/operations/artist.tsx +++ b/packages/cletus/src/operations/artist.tsx @@ -480,3 +480,312 @@ export const image_attach = operationOf< ), }); +// ============================================================================ +// 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', 'scatter'], + multivariateComparison: ['groupedBar', 'stackedBar', 'radar', 'parallel'], +}; + +// ECharts option type (simplified - in reality it's much more complex) +export type EChartsOption = Record; + +export type ChartDisplayInput = { + chartGroup: ChartGroup; + title?: string; + data: any[]; + variantOptions?: Partial>>; + defaultVariant?: ChartVariant; +}; + +export type ChartDisplayOutput = { + chartGroup: ChartGroup; + availableVariants: ChartVariant[]; + currentVariant: ChartVariant; + option: EChartsOption; + data: any[]; + variantOptions: Partial>>; +}; + +// ============================================================================ +// Chart Display Operation +// ============================================================================ + +export const chart_display = operationOf< + ChartDisplayInput, + ChartDisplayOutput +>({ + mode: 'local', + signature: 'chart_display(chartGroup: string, title?: string, data: any[], variantOptions?: object, defaultVariant?: string)', + status: (input) => `Displaying ${input.chartGroup} chart`, + analyze: async ({ input }) => { + // Local operation, no analysis needed + return { + analysis: `This will display a ${input.chartGroup} chart with ${input.data?.length || 0} data points`, + doable: true, + }; + }, + do: async ({ input }) => { + const availableVariants = ChartGroupVariants[input.chartGroup]; + const currentVariant = input.defaultVariant || availableVariants[0]; + const variantOptions = input.variantOptions || {}; + + // Build base option from input + const baseOption: EChartsOption = { + title: input.title ? { text: input.title, left: 'center' } : undefined, + tooltip: { trigger: 'item' }, + legend: {}, + series: [], + }; + + // Apply variant-specific options + const variantOption = variantOptions[currentVariant] || {}; + const option = applyVariantToOption(baseOption, currentVariant, input.data, variantOption); + + return { + chartGroup: input.chartGroup, + availableVariants, + currentVariant, + option, + data: input.data, + variantOptions, + }; + }, + render: (op, ai, showInput, showOutput) => renderOperation( + op, + `ChartDisplay(${op.input.chartGroup}, ${op.input.data?.length || 0} points)`, + (op) => { + if (op.output) { + return `Displaying ${op.output.chartGroup} chart as ${op.output.currentVariant}`; + } + return null; + }, + showInput, showOutput + ), +}); + +/** + * Apply variant-specific transformations to the base option + */ +function applyVariantToOption( + baseOption: EChartsOption, + variant: ChartVariant, + data: any[], + 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 'orderedBar': + case 'horizontalOrderedBar': + case 'tree': + case 'sankey': + case 'funnel': + case 'map': + case 'groupedBar': + case 'stackedBar': + case 'radar': + case 'parallel': + // For now, use sensible defaults for other chart types + option.series = [{ + type: variant.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''), + data, + }]; + break; + } + + // Merge variant-specific options + return deepMerge(option, variantOption); +} + +/** + * Deep merge two objects + */ +function deepMerge(target: any, source: any): any { + 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; +} + +function isObject(item: any): boolean { + return item && typeof item === 'object' && !Array.isArray(item); +} + diff --git a/packages/cletus/src/schemas.ts b/packages/cletus/src/schemas.ts index 9de5bc4..4d3fa3c 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', // clerk 'file_search', 'file_summary', diff --git a/packages/cletus/src/tools/artist.ts b/packages/cletus/src/tools/artist.ts index 4080f41..dcf595e 100644 --- a/packages/cletus/src/tools/artist.ts +++ b/packages/cletus/src/tools/artist.ts @@ -116,6 +116,55 @@ Example: Attach an image file: call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx), }); + 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, scatter (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({ + chartGroup: z.enum([ + 'partToWhole', + 'categoryComparison', + 'timeSeries', + 'distribution', + 'correlation', + 'ranking', + 'hierarchical', + 'flow', + 'geospatial', + 'multivariateComparison', + ]).describe('The type of chart to display'), + title: z.string().optional().describe('Optional chart title'), + data: z.array(z.any()).describe('Array of data points to visualize'), + variantOptions: z.record(z.any()).optional().describe('Optional variant-specific ECharts options to customize each variant'), + defaultVariant: z.string().optional().describe('Optional default variant to display (must be valid for the chartGroup)'), + ...globalToolProperties, + }), + input: getOperationInput('chart_display'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'chart_display', input }, ctx), + }); + return [ imageGenerate, imageEdit, @@ -123,6 +172,7 @@ Example: Attach an image file: imageDescribe, imageFind, imageAttach, + chartDisplay, ] as [ typeof imageGenerate, typeof imageEdit, @@ -130,5 +180,6 @@ Example: Attach an image file: typeof imageDescribe, typeof imageFind, typeof imageAttach, + typeof chartDisplay, ]; } From 824acfc4463f87feffe6b81037e7f145ac4a0e4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 03:56:41 +0000 Subject: [PATCH 03/12] Add documentation and tests for chart_display operation Co-authored-by: ClickerMonkey <421233+ClickerMonkey@users.noreply.github.com> --- packages/cletus/docs/chart-display.md | 171 ++++++++++++++++++ .../__tests__/chart-display.test.ts | 97 ++++++++++ 2 files changed, 268 insertions(+) create mode 100644 packages/cletus/docs/chart-display.md create mode 100644 packages/cletus/src/operations/__tests__/chart-display.test.ts diff --git a/packages/cletus/docs/chart-display.md b/packages/cletus/docs/chart-display.md new file mode 100644 index 0000000..0c45635 --- /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`, `scatter` + +### 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/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(); + }); +}); From 82eb9d177ffc861bf57f5006b104f60ef018543c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 04:00:39 +0000 Subject: [PATCH 04/12] Address code review: extract utility functions and fix chart type mapping Co-authored-by: ClickerMonkey <421233+ClickerMonkey@users.noreply.github.com> --- .../cletus/src/browser/operations/artist.tsx | 69 +++++++++---------- packages/cletus/src/common.ts | 2 + packages/cletus/src/operations/artist.tsx | 69 +++++++++---------- packages/cletus/src/shared.ts | 40 +++++++++++ 4 files changed, 108 insertions(+), 72 deletions(-) diff --git a/packages/cletus/src/browser/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index f97718f..2711cbd 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { abbreviate, pluralize } from '../../shared'; +import { abbreviate, deepMerge, isObject, pluralize } from '../../shared'; import { createRenderer } from './render'; import { ClickableImage } from '../components/ImageViewer'; import * as echarts from 'echarts'; @@ -316,55 +316,52 @@ function buildOptionForVariant(variant: ChartVariant, data: any[], variantOption case 'scatter': case 'effectScatter': case 'heatmap': - case 'orderedBar': - case 'horizontalOrderedBar': case 'tree': case 'sankey': case 'funnel': case 'map': - case 'groupedBar': - case 'stackedBar': case 'radar': case 'parallel': - // For now, use sensible defaults for other chart types + // Use the variant name directly as ECharts type (these are already correct) baseOption.series = [{ - type: variant.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''), + type: variant, data, }]; break; - } - - // Deep merge variant-specific options - return deepMerge(baseOption, variantOption); -} - -/** - * Deep merge two objects - */ -function deepMerge(target: any, source: any): any { - 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]); - } + + 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) }; + baseOption.xAxis = { type: 'value' }; } else { - output[key] = source[key]; + baseOption.xAxis = { type: 'category', data: sortedData.map((d: any) => d.name) }; + baseOption.yAxis = { type: 'value' }; } - }); + 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) }; + baseOption.yAxis = { type: 'value' }; + baseOption.series = [{ + type: 'bar', + data: data.map((d: any) => d.value), + stack: variant === 'stackedBar' ? 'total' : undefined, + }]; + break; } - - return output; -} -function isObject(item: any): boolean { - return item && typeof item === 'object' && !Array.isArray(item); + // Deep merge variant-specific options + return deepMerge(baseOption, variantOption); } // ============================================================================ 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/operations/artist.tsx b/packages/cletus/src/operations/artist.tsx index 48d24d8..a556968 100644 --- a/packages/cletus/src/operations/artist.tsx +++ b/packages/cletus/src/operations/artist.tsx @@ -2,7 +2,7 @@ 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"; @@ -738,54 +738,51 @@ function applyVariantToOption( case 'scatter': case 'effectScatter': case 'heatmap': - case 'orderedBar': - case 'horizontalOrderedBar': case 'tree': case 'sankey': case 'funnel': case 'map': - case 'groupedBar': - case 'stackedBar': case 'radar': case 'parallel': - // For now, use sensible defaults for other chart types + // Use the variant name directly as ECharts type (these are already correct) option.series = [{ - type: variant.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''), + type: variant, data, }]; break; - } - - // Merge variant-specific options - return deepMerge(option, variantOption); -} - -/** - * Deep merge two objects - */ -function deepMerge(target: any, source: any): any { - 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]); - } + + 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 { - output[key] = source[key]; + 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; } - - return output; -} -function isObject(item: any): boolean { - return item && typeof item === 'object' && !Array.isArray(item); + // Merge variant-specific options + return deepMerge(option, variantOption); } 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); +} From 9f736bb01d3db41a171dd3a490a3a386b26ea84e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 04:04:43 +0000 Subject: [PATCH 05/12] Remove scatter variant duplication and document code structure Co-authored-by: ClickerMonkey <421233+ClickerMonkey@users.noreply.github.com> --- packages/cletus/docs/chart-display.md | 2 +- packages/cletus/src/browser/operations/artist.tsx | 7 +++++++ packages/cletus/src/operations/artist.tsx | 9 ++++++++- packages/cletus/src/tools/artist.ts | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/cletus/docs/chart-display.md b/packages/cletus/docs/chart-display.md index 0c45635..ac6e741 100644 --- a/packages/cletus/docs/chart-display.md +++ b/packages/cletus/docs/chart-display.md @@ -61,7 +61,7 @@ Shows flow or funnel processes. ### Geospatial (`geospatial`) Displays geographic data. -- **Variants**: `map`, `scatter` +- **Variants**: `map` ### Multivariate Comparison (`multivariateComparison`) Compares multiple variables simultaneously. diff --git a/packages/cletus/src/browser/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index 2711cbd..76ac439 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -189,6 +189,13 @@ const ChartDisplay: React.FC<{ /** * 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: any[], variantOption: any): any { const baseOption: any = { diff --git a/packages/cletus/src/operations/artist.tsx b/packages/cletus/src/operations/artist.tsx index a556968..4fb59e8 100644 --- a/packages/cletus/src/operations/artist.tsx +++ b/packages/cletus/src/operations/artist.tsx @@ -527,7 +527,7 @@ export const ChartGroupVariants: Record = { ranking: ['orderedBar', 'horizontalOrderedBar'], hierarchical: ['treemap', 'sunburst', 'tree'], flow: ['sankey', 'funnel'], - geospatial: ['map', 'scatter'], + geospatial: ['map'], // Removed scatter to avoid duplication with correlation group multivariateComparison: ['groupedBar', 'stackedBar', 'radar', 'parallel'], }; @@ -610,6 +610,13 @@ export const chart_display = 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, diff --git a/packages/cletus/src/tools/artist.ts b/packages/cletus/src/tools/artist.ts index dcf595e..b0e9630 100644 --- a/packages/cletus/src/tools/artist.ts +++ b/packages/cletus/src/tools/artist.ts @@ -130,7 +130,7 @@ Chart groups and their available variants: - ranking: orderedBar, horizontalOrderedBar (for ranked data) - hierarchical: treemap, sunburst, tree (for hierarchical data) - flow: sankey, funnel (for flow/process data) -- geospatial: map, scatter (for geographic 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.: From b13c20dc9b698f0bb96baa01e1dea008ab78a3b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 04:25:12 +0000 Subject: [PATCH 06/12] Address PR feedback: improve React imports, simplify signature, add discriminated union schemas, and browser-only metadata Co-authored-by: ClickerMonkey <421233+ClickerMonkey@users.noreply.github.com> --- .../cletus/src/browser/operations/artist.tsx | 12 +- packages/cletus/src/operations/artist.tsx | 2 +- packages/cletus/src/tools/artist.ts | 158 +++++++++++++++--- 3 files changed, 146 insertions(+), 26 deletions(-) diff --git a/packages/cletus/src/browser/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index 76ac439..126663d 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { abbreviate, deepMerge, isObject, pluralize } from '../../shared'; import { createRenderer } from './render'; import { ClickableImage } from '../components/ImageViewer'; @@ -122,12 +122,12 @@ const ChartDisplay: React.FC<{ data: any[]; variantOptions: any; }> = ({ chartGroup, availableVariants, currentVariant: initialVariant, option: initialOption, data, variantOptions }) => { - const chartRef = React.useRef(null); - const chartInstanceRef = React.useRef(null); - const [currentVariant, setCurrentVariant] = React.useState(initialVariant); + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + const [currentVariant, setCurrentVariant] = useState(initialVariant); // Initialize chart - React.useEffect(() => { + useEffect(() => { if (!chartRef.current) return; const chart = echarts.init(chartRef.current); @@ -147,7 +147,7 @@ const ChartDisplay: React.FC<{ }, []); // Update chart when variant changes - React.useEffect(() => { + useEffect(() => { if (!chartInstanceRef.current) return; const newOption = buildOptionForVariant(currentVariant, data, variantOptions[currentVariant] || {}); diff --git a/packages/cletus/src/operations/artist.tsx b/packages/cletus/src/operations/artist.tsx index 4fb59e8..a340f75 100644 --- a/packages/cletus/src/operations/artist.tsx +++ b/packages/cletus/src/operations/artist.tsx @@ -560,7 +560,7 @@ export const chart_display = operationOf< ChartDisplayOutput >({ mode: 'local', - signature: 'chart_display(chartGroup: string, title?: string, data: any[], variantOptions?: object, defaultVariant?: string)', + signature: 'chart_display(chartGroup, title?, data, variantOptions?, defaultVariant?)', status: (input) => `Displaying ${input.chartGroup} chart`, analyze: async ({ input }) => { // Local operation, no analysis needed diff --git a/packages/cletus/src/tools/artist.ts b/packages/cletus/src/tools/artist.ts index b0e9630..ead56e1 100644 --- a/packages/cletus/src/tools/artist.ts +++ b/packages/cletus/src/tools/artist.ts @@ -116,6 +116,132 @@ Example: Attach an image file: call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx), }); + // Chart display schemas - one for each chart group + const ChartDataPointSchema = z.object({ + name: z.string().describe('Name/label for the data point'), + value: z.number().describe('Numeric value for the data point'), + }); + + 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.record( + z.enum(['pie', 'donut', 'treemap', 'sunburst']), + z.record(z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['pie', 'donut', 'treemap', 'sunburst']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['bar', 'horizontalBar', 'pictorialBar']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['line', 'area', 'step', 'smoothLine']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['histogram', 'boxplot']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['scatter', 'effectScatter', 'heatmap']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['orderedBar', 'horizontalOrderedBar']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['treemap', 'sunburst', 'tree']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['sankey', 'funnel']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['map']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + const chartDisplay = ai.tool({ name: 'chart_display', description: 'Display data as an interactive chart in the browser UI', @@ -142,25 +268,19 @@ 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({ - chartGroup: z.enum([ - 'partToWhole', - 'categoryComparison', - 'timeSeries', - 'distribution', - 'correlation', - 'ranking', - 'hierarchical', - 'flow', - 'geospatial', - 'multivariateComparison', - ]).describe('The type of chart to display'), - title: z.string().optional().describe('Optional chart title'), - data: z.array(z.any()).describe('Array of data points to visualize'), - variantOptions: z.record(z.any()).optional().describe('Optional variant-specific ECharts options to customize each variant'), - defaultVariant: z.string().optional().describe('Optional default variant to display (must be valid for the chartGroup)'), - ...globalToolProperties, - }), + schema: z.discriminatedUnion('chartGroup', [ + PartToWholeChartSchema, + CategoryComparisonChartSchema, + TimeSeriesChartSchema, + DistributionChartSchema, + CorrelationChartSchema, + RankingChartSchema, + HierarchicalChartSchema, + FlowChartSchema, + GeospatialChartSchema, + MultivariateComparisonChartSchema, + ]), + metadata: { onlyClient: 'browser' }, input: getOperationInput('chart_display'), call: async (input, _, ctx) => ctx.ops.handle({ type: 'chart_display', input }, ctx), }); From 7f108e20cf4b40e1e1bff5e9f7ef629e3a14d616 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 04:43:34 +0000 Subject: [PATCH 07/12] Refactor chart schemas: move outside function, use union with globalToolProperties, replace any types Co-authored-by: ClickerMonkey <421233+ClickerMonkey@users.noreply.github.com> --- .../cletus/src/browser/operations/artist.tsx | 13 +- packages/cletus/src/operations/artist.tsx | 40 ++- packages/cletus/src/tools/artist.ts | 145 +-------- packages/cletus/src/tools/artist.ts.backup | 305 ++++++++++++++++++ packages/cletus/src/tools/artist_new.ts | 117 +++++++ packages/cletus/src/tools/artist_schemas.ts | 138 ++++++++ 6 files changed, 593 insertions(+), 165 deletions(-) create mode 100644 packages/cletus/src/tools/artist.ts.backup create mode 100644 packages/cletus/src/tools/artist_new.ts create mode 100644 packages/cletus/src/tools/artist_schemas.ts diff --git a/packages/cletus/src/browser/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index 126663d..7a8d113 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -3,7 +3,8 @@ import { abbreviate, deepMerge, isObject, pluralize } from '../../shared'; import { createRenderer } from './render'; import { ClickableImage } from '../components/ImageViewer'; import * as echarts from 'echarts'; -import type { ChartVariant } from '../../operations/artist'; +import type { ChartVariant, EChartsOption } from '../../operations/artist'; +import type { ChartDataPoint } from '../../tools/artist_schemas'; const renderer = createRenderer({ borderColor: "border-neon-pink/30", @@ -118,9 +119,9 @@ const ChartDisplay: React.FC<{ chartGroup: string; availableVariants: ChartVariant[]; currentVariant: ChartVariant; - option: any; - data: any[]; - variantOptions: any; + option: EChartsOption; + data: ChartDataPoint[]; + variantOptions: Partial>>; }> = ({ chartGroup, availableVariants, currentVariant: initialVariant, option: initialOption, data, variantOptions }) => { const chartRef = useRef(null); const chartInstanceRef = useRef(null); @@ -197,8 +198,8 @@ const ChartDisplay: React.FC<{ * * If making changes here, ensure the same changes are made in operations/artist.tsx */ -function buildOptionForVariant(variant: ChartVariant, data: any[], variantOption: any): any { - const baseOption: any = { +function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], variantOption: Partial): EChartsOption { + const baseOption: EChartsOption = { tooltip: { trigger: 'item' }, legend: {}, series: [], diff --git a/packages/cletus/src/operations/artist.tsx b/packages/cletus/src/operations/artist.tsx index a340f75..c7c2118 100644 --- a/packages/cletus/src/operations/artist.tsx +++ b/packages/cletus/src/operations/artist.tsx @@ -8,6 +8,7 @@ import { getImagePath } from "../file-manager"; import { fileIsReadable, searchFiles } from "../helpers/files"; import { renderOperation } from "../helpers/render"; import { operationOf } from "./types"; +import type { ChartConfig, ChartDataPoint } from "../tools/artist_schemas"; function resolveImage(cwd: string, imagePath: string): string { const [_, _filename, filepath] = imagePath.match(/^\[([^\]]+)\]\(([^)]+)\)$/) || []; @@ -532,14 +533,10 @@ export const ChartGroupVariants: Record = { }; // ECharts option type (simplified - in reality it's much more complex) -export type EChartsOption = Record; +export type EChartsOption = Record; export type ChartDisplayInput = { - chartGroup: ChartGroup; - title?: string; - data: any[]; - variantOptions?: Partial>>; - defaultVariant?: ChartVariant; + chart: ChartConfig; }; export type ChartDisplayOutput = { @@ -547,7 +544,7 @@ export type ChartDisplayOutput = { availableVariants: ChartVariant[]; currentVariant: ChartVariant; option: EChartsOption; - data: any[]; + data: ChartDataPoint[]; variantOptions: Partial>>; }; @@ -560,44 +557,45 @@ export const chart_display = operationOf< ChartDisplayOutput >({ mode: 'local', - signature: 'chart_display(chartGroup, title?, data, variantOptions?, defaultVariant?)', - status: (input) => `Displaying ${input.chartGroup} chart`, + 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.chartGroup} chart with ${input.data?.length || 0} data points`, + analysis: `This will display a ${input.chart.chartGroup} chart with ${input.chart.data?.length || 0} data points`, doable: true, }; }, do: async ({ input }) => { - const availableVariants = ChartGroupVariants[input.chartGroup]; - const currentVariant = input.defaultVariant || availableVariants[0]; - const variantOptions = input.variantOptions || {}; + const { chartGroup, data, title, variantOptions, defaultVariant } = input.chart; + const availableVariants = ChartGroupVariants[chartGroup]; + const currentVariant = defaultVariant || availableVariants[0]; + const variantOpts = variantOptions || {}; // Build base option from input const baseOption: EChartsOption = { - title: input.title ? { text: input.title, left: 'center' } : undefined, + title: title ? { text: title, left: 'center' } : undefined, tooltip: { trigger: 'item' }, legend: {}, series: [], }; // Apply variant-specific options - const variantOption = variantOptions[currentVariant] || {}; - const option = applyVariantToOption(baseOption, currentVariant, input.data, variantOption); + const variantOption = variantOpts[currentVariant] || {}; + const option = applyVariantToOption(baseOption, currentVariant, data, variantOption); return { - chartGroup: input.chartGroup, + chartGroup, availableVariants, currentVariant, option, - data: input.data, - variantOptions, + data, + variantOptions: variantOpts, }; }, render: (op, ai, showInput, showOutput) => renderOperation( op, - `ChartDisplay(${op.input.chartGroup}, ${op.input.data?.length || 0} points)`, + `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}`; @@ -621,7 +619,7 @@ export const chart_display = operationOf< function applyVariantToOption( baseOption: EChartsOption, variant: ChartVariant, - data: any[], + data: ChartDataPoint[], variantOption: Partial ): EChartsOption { const option = { ...baseOption }; diff --git a/packages/cletus/src/tools/artist.ts b/packages/cletus/src/tools/artist.ts index ead56e1..d3ab37c 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 './artist_schemas'; /** * Create artist tools for image operations @@ -116,132 +117,8 @@ Example: Attach an image file: call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx), }); - // Chart display schemas - one for each chart group - const ChartDataPointSchema = z.object({ - name: z.string().describe('Name/label for the data point'), - value: z.number().describe('Numeric value for the data point'), - }); - - 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.record( - z.enum(['pie', 'donut', 'treemap', 'sunburst']), - z.record(z.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['pie', 'donut', 'treemap', 'sunburst']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['bar', 'horizontalBar', 'pictorialBar']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['line', 'area', 'step', 'smoothLine']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['histogram', 'boxplot']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['scatter', 'effectScatter', 'heatmap']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['orderedBar', 'horizontalOrderedBar']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['treemap', 'sunburst', 'tree']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['sankey', 'funnel']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['map']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - + // 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', @@ -268,18 +145,10 @@ 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.discriminatedUnion('chartGroup', [ - PartToWholeChartSchema, - CategoryComparisonChartSchema, - TimeSeriesChartSchema, - DistributionChartSchema, - CorrelationChartSchema, - RankingChartSchema, - HierarchicalChartSchema, - FlowChartSchema, - GeospatialChartSchema, - MultivariateComparisonChartSchema, - ]), + schema: z.object({ + chart: ChartConfigSchema, + ...globalToolProperties, + }), metadata: { onlyClient: 'browser' }, input: getOperationInput('chart_display'), call: async (input, _, ctx) => ctx.ops.handle({ type: 'chart_display', input }, ctx), diff --git a/packages/cletus/src/tools/artist.ts.backup b/packages/cletus/src/tools/artist.ts.backup new file mode 100644 index 0000000..ead56e1 --- /dev/null +++ b/packages/cletus/src/tools/artist.ts.backup @@ -0,0 +1,305 @@ +import { z } from 'zod'; +import { globalToolProperties, type CletusAI } from '../ai'; +import { getOperationInput } from '../operations/types'; + +/** + * Create artist tools for image operations + * Images are stored in .cletus/images/ and referenced via [filename](filepath) syntax +*/ +export function createArtistTools(ai: CletusAI) { + const imageGenerate = ai.tool({ + name: 'image_generate', + description: 'Generate one or more images from a text prompt', + instructions: `Use this to create new images. Generated images are saved to .cletus/images/ and returned as file paths. + +Example: Generate a landscape image: +{ "prompt": "A serene mountain landscape at sunset with a lake in the foreground", "n": 1 } + +{{modeInstructions}}`, + schema: z.object({ + prompt: z.string().describe('Text description of the image to generate'), + n: z.number().optional().default(1).describe('Number of images to generate (default: 1)'), + ...globalToolProperties, + }), + input: getOperationInput('image_generate'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_generate', input }, ctx), + }); + + const imageEdit = ai.tool({ + name: 'image_edit', + description: 'Edit an existing image based on a text prompt', + instructions: `Use this to modify an existing image. Provide a file path (relative or absolute path or[filename](filepath)) and a description of the edit. The edited image will be saved as a new file. + +Example: Add a sunset effect to an image: +{ "prompt": "Add warm sunset colors and lighting", "path": "images/photo.jpg" } + +{{modeInstructions}}`, + schema: z.object({ + prompt: z.string().describe('Description of how to edit the image'), + path: z.string().describe('Path to the image to edit (relative path or absolute path or [filename](filepath) URL)'), + ...globalToolProperties, + }), + input: getOperationInput('image_edit'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_edit', input }, ctx), + }); + + const imageAnalyze = ai.tool({ + name: 'image_analyze', + description: 'Analyze one or more images with AI and answer questions', + instructions: `Use this to understand what's in images or answer specific questions about them. Can analyze multiple images together for comparison. + +Example: Compare two designs: +{ "prompt": "What are the main differences between these two UI designs?", "paths": ["designs/v1.png", "designs/v2.png"] } + +{{modeInstructions}}`, + schema: z.object({ + prompt: z.string().describe('Question or analysis request about the images'), + paths: z.array(z.string()).describe('Paths to images to analyze (relative paths or absolute file or [filename](filepath))'), + maxCharacters: z.number().optional().default(2084).describe('Maximum response length (maxCharacters/4 = maxTokens, default: 2084)'), + ...globalToolProperties, + }), + input: getOperationInput('image_analyze'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_analyze', input }, ctx), + }); + + const imageDescribe = ai.tool({ + name: 'image_describe', + description: 'Get a detailed description of what\'s in an image', + instructions: `Use this to generate a comprehensive description of an image's contents without a specific question. + +Example: Describe a screenshot: +{ "path": "screenshots/dashboard.png" } + +{{modeInstructions}}`, + schema: z.object({ + path: z.string().describe('Path to the image to describe (relative path or absolute path or [filename](filepath))'), + ...globalToolProperties, + }), + input: getOperationInput('image_describe'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_describe', input }, ctx), + }); + + const imageFind = ai.tool({ + name: 'image_find', + description: 'Find images matching a description using semantic search', + instructions: `Use this to search for images in a directory that match a text description. Each image is analyzed and embedded, then compared to the prompt embedding for similarity scoring. This operation can be slow for large numbers of images. + +Example: Find images of people in photos: +{ "query": "photos containing people smiling", "glob": "photos/**/*.jpg", "n": 5 } + +{{modeInstructions}}`, + schema: z.object({ + query: z.string().describe('Description of what to find in images'), + glob: z.string().describe('Glob pattern for image files to search (e.g., "**/*.png", "photos/*.jpg")'), + maxImages: z.number().optional().default(100).describe('Maximum number of images to analyze (default: 100)'), + n: z.number().optional().default(5).describe('Number of top results to return (default: 5)'), + ...globalToolProperties, + }), + input: getOperationInput('image_find'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_find', input }, ctx), + }); + + const imageAttach = ai.tool({ + name: 'image_attach', + description: 'Attach an image to the chat for the user & AI assistant to see', + instructions: `Use this to attach an image file to the chat conversation. The image will be added as a user message and displayed in the chat. Accepts both absolute and relative paths. + +Example: Attach an image file: +{ "path": "images/diagram.png" } + +{{modeInstructions}}`, + schema: z.object({ + path: z.string().describe('Path to the image file to attach (absolute or relative)'), + ...globalToolProperties, + }), + input: getOperationInput('image_attach'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx), + }); + + // Chart display schemas - one for each chart group + const ChartDataPointSchema = z.object({ + name: z.string().describe('Name/label for the data point'), + value: z.number().describe('Numeric value for the data point'), + }); + + 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.record( + z.enum(['pie', 'donut', 'treemap', 'sunburst']), + z.record(z.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['pie', 'donut', 'treemap', 'sunburst']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['bar', 'horizontalBar', 'pictorialBar']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['line', 'area', 'step', 'smoothLine']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['histogram', 'boxplot']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['scatter', 'effectScatter', 'heatmap']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['orderedBar', 'horizontalOrderedBar']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['treemap', 'sunburst', 'tree']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['sankey', 'funnel']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['map']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.unknown()) + ).optional().describe('Optional variant-specific ECharts options'), + defaultVariant: z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']).optional().describe('Default variant to display'), + ...globalToolProperties, + }); + + 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.discriminatedUnion('chartGroup', [ + PartToWholeChartSchema, + CategoryComparisonChartSchema, + TimeSeriesChartSchema, + DistributionChartSchema, + CorrelationChartSchema, + RankingChartSchema, + HierarchicalChartSchema, + FlowChartSchema, + GeospatialChartSchema, + MultivariateComparisonChartSchema, + ]), + metadata: { onlyClient: 'browser' }, + input: getOperationInput('chart_display'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'chart_display', input }, ctx), + }); + + return [ + imageGenerate, + imageEdit, + imageAnalyze, + imageDescribe, + imageFind, + imageAttach, + chartDisplay, + ] as [ + typeof imageGenerate, + typeof imageEdit, + typeof imageAnalyze, + typeof imageDescribe, + typeof imageFind, + typeof imageAttach, + typeof chartDisplay, + ]; +} diff --git a/packages/cletus/src/tools/artist_new.ts b/packages/cletus/src/tools/artist_new.ts new file mode 100644 index 0000000..7800c88 --- /dev/null +++ b/packages/cletus/src/tools/artist_new.ts @@ -0,0 +1,117 @@ +import { z } from 'zod'; +import { globalToolProperties, type CletusAI } from '../ai'; +import { getOperationInput } from '../operations/types'; + +/** + * Create artist tools for image operations + * Images are stored in .cletus/images/ and referenced via [filename](filepath) syntax +*/ +export function createArtistTools(ai: CletusAI) { + const imageGenerate = ai.tool({ + name: 'image_generate', + description: 'Generate one or more images from a text prompt', + instructions: `Use this to create new images. Generated images are saved to .cletus/images/ and returned as file paths. + +Example: Generate a landscape image: +{ "prompt": "A serene mountain landscape at sunset with a lake in the foreground", "n": 1 } + +{{modeInstructions}}`, + schema: z.object({ + prompt: z.string().describe('Text description of the image to generate'), + n: z.number().optional().default(1).describe('Number of images to generate (default: 1)'), + ...globalToolProperties, + }), + input: getOperationInput('image_generate'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_generate', input }, ctx), + }); + + const imageEdit = ai.tool({ + name: 'image_edit', + description: 'Edit an existing image based on a text prompt', + instructions: `Use this to modify an existing image. Provide a file path (relative or absolute path or[filename](filepath)) and a description of the edit. The edited image will be saved as a new file. + +Example: Add a sunset effect to an image: +{ "prompt": "Add warm sunset colors and lighting", "path": "images/photo.jpg" } + +{{modeInstructions}}`, + schema: z.object({ + prompt: z.string().describe('Description of how to edit the image'), + path: z.string().describe('Path to the image to edit (relative path or absolute path or [filename](filepath) URL)'), + ...globalToolProperties, + }), + input: getOperationInput('image_edit'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_edit', input }, ctx), + }); + + const imageAnalyze = ai.tool({ + name: 'image_analyze', + description: 'Analyze one or more images with AI and answer questions', + instructions: `Use this to understand what's in images or answer specific questions about them. Can analyze multiple images together for comparison. + +Example: Compare two designs: +{ "prompt": "What are the main differences between these two UI designs?", "paths": ["designs/v1.png", "designs/v2.png"] } + +{{modeInstructions}}`, + schema: z.object({ + prompt: z.string().describe('Question or analysis request about the images'), + paths: z.array(z.string()).describe('Paths to images to analyze (relative paths or absolute file or [filename](filepath))'), + maxCharacters: z.number().optional().default(2084).describe('Maximum response length (maxCharacters/4 = maxTokens, default: 2084)'), + ...globalToolProperties, + }), + input: getOperationInput('image_analyze'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_analyze', input }, ctx), + }); + + const imageDescribe = ai.tool({ + name: 'image_describe', + description: 'Get a detailed description of what\'s in an image', + instructions: `Use this to generate a comprehensive description of an image's contents without a specific question. + +Example: Describe a screenshot: +{ "path": "screenshots/dashboard.png" } + +{{modeInstructions}}`, + schema: z.object({ + path: z.string().describe('Path to the image to describe (relative path or absolute path or [filename](filepath))'), + ...globalToolProperties, + }), + input: getOperationInput('image_describe'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_describe', input }, ctx), + }); + + const imageFind = ai.tool({ + name: 'image_find', + description: 'Find images matching a description using semantic search', + instructions: `Use this to search for images in a directory that match a text description. Each image is analyzed and embedded, then compared to the prompt embedding for similarity scoring. This operation can be slow for large numbers of images. + +Example: Find images of people in photos: +{ "query": "photos containing people smiling", "glob": "photos/**/*.jpg", "n": 5 } + +{{modeInstructions}}`, + schema: z.object({ + query: z.string().describe('Description of what to find in images'), + glob: z.string().describe('Glob pattern for image files to search (e.g., "**/*.png", "photos/*.jpg")'), + maxImages: z.number().optional().default(100).describe('Maximum number of images to analyze (default: 100)'), + n: z.number().optional().default(5).describe('Number of top results to return (default: 5)'), + ...globalToolProperties, + }), + input: getOperationInput('image_find'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_find', input }, ctx), + }); + + const imageAttach = ai.tool({ + name: 'image_attach', + description: 'Attach an image to the chat for the user & AI assistant to see', + instructions: `Use this to attach an image file to the chat conversation. The image will be added as a user message and displayed in the chat. Accepts both absolute and relative paths. + +Example: Attach an image file: +{ "path": "images/diagram.png" } + +{{modeInstructions}}`, + schema: z.object({ + path: z.string().describe('Path to the image file to attach (absolute or relative)'), + ...globalToolProperties, + }), + input: getOperationInput('image_attach'), + call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx), + }); diff --git a/packages/cletus/src/tools/artist_schemas.ts b/packages/cletus/src/tools/artist_schemas.ts new file mode 100644 index 0000000..4b9830a --- /dev/null +++ b/packages/cletus/src/tools/artist_schemas.ts @@ -0,0 +1,138 @@ +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.record( + z.enum(['pie', 'donut', 'treemap', 'sunburst']), + z.record(z.unknown()) + ).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.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.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.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.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.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.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.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.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.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; From f969c2cdcb8574a0aa5fe073665cbd9dd137aeee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 04:44:19 +0000 Subject: [PATCH 08/12] Remove temporary backup files Co-authored-by: ClickerMonkey <421233+ClickerMonkey@users.noreply.github.com> --- packages/cletus/src/tools/artist.ts.backup | 305 --------------------- packages/cletus/src/tools/artist_new.ts | 117 -------- 2 files changed, 422 deletions(-) delete mode 100644 packages/cletus/src/tools/artist.ts.backup delete mode 100644 packages/cletus/src/tools/artist_new.ts diff --git a/packages/cletus/src/tools/artist.ts.backup b/packages/cletus/src/tools/artist.ts.backup deleted file mode 100644 index ead56e1..0000000 --- a/packages/cletus/src/tools/artist.ts.backup +++ /dev/null @@ -1,305 +0,0 @@ -import { z } from 'zod'; -import { globalToolProperties, type CletusAI } from '../ai'; -import { getOperationInput } from '../operations/types'; - -/** - * Create artist tools for image operations - * Images are stored in .cletus/images/ and referenced via [filename](filepath) syntax -*/ -export function createArtistTools(ai: CletusAI) { - const imageGenerate = ai.tool({ - name: 'image_generate', - description: 'Generate one or more images from a text prompt', - instructions: `Use this to create new images. Generated images are saved to .cletus/images/ and returned as file paths. - -Example: Generate a landscape image: -{ "prompt": "A serene mountain landscape at sunset with a lake in the foreground", "n": 1 } - -{{modeInstructions}}`, - schema: z.object({ - prompt: z.string().describe('Text description of the image to generate'), - n: z.number().optional().default(1).describe('Number of images to generate (default: 1)'), - ...globalToolProperties, - }), - input: getOperationInput('image_generate'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_generate', input }, ctx), - }); - - const imageEdit = ai.tool({ - name: 'image_edit', - description: 'Edit an existing image based on a text prompt', - instructions: `Use this to modify an existing image. Provide a file path (relative or absolute path or[filename](filepath)) and a description of the edit. The edited image will be saved as a new file. - -Example: Add a sunset effect to an image: -{ "prompt": "Add warm sunset colors and lighting", "path": "images/photo.jpg" } - -{{modeInstructions}}`, - schema: z.object({ - prompt: z.string().describe('Description of how to edit the image'), - path: z.string().describe('Path to the image to edit (relative path or absolute path or [filename](filepath) URL)'), - ...globalToolProperties, - }), - input: getOperationInput('image_edit'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_edit', input }, ctx), - }); - - const imageAnalyze = ai.tool({ - name: 'image_analyze', - description: 'Analyze one or more images with AI and answer questions', - instructions: `Use this to understand what's in images or answer specific questions about them. Can analyze multiple images together for comparison. - -Example: Compare two designs: -{ "prompt": "What are the main differences between these two UI designs?", "paths": ["designs/v1.png", "designs/v2.png"] } - -{{modeInstructions}}`, - schema: z.object({ - prompt: z.string().describe('Question or analysis request about the images'), - paths: z.array(z.string()).describe('Paths to images to analyze (relative paths or absolute file or [filename](filepath))'), - maxCharacters: z.number().optional().default(2084).describe('Maximum response length (maxCharacters/4 = maxTokens, default: 2084)'), - ...globalToolProperties, - }), - input: getOperationInput('image_analyze'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_analyze', input }, ctx), - }); - - const imageDescribe = ai.tool({ - name: 'image_describe', - description: 'Get a detailed description of what\'s in an image', - instructions: `Use this to generate a comprehensive description of an image's contents without a specific question. - -Example: Describe a screenshot: -{ "path": "screenshots/dashboard.png" } - -{{modeInstructions}}`, - schema: z.object({ - path: z.string().describe('Path to the image to describe (relative path or absolute path or [filename](filepath))'), - ...globalToolProperties, - }), - input: getOperationInput('image_describe'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_describe', input }, ctx), - }); - - const imageFind = ai.tool({ - name: 'image_find', - description: 'Find images matching a description using semantic search', - instructions: `Use this to search for images in a directory that match a text description. Each image is analyzed and embedded, then compared to the prompt embedding for similarity scoring. This operation can be slow for large numbers of images. - -Example: Find images of people in photos: -{ "query": "photos containing people smiling", "glob": "photos/**/*.jpg", "n": 5 } - -{{modeInstructions}}`, - schema: z.object({ - query: z.string().describe('Description of what to find in images'), - glob: z.string().describe('Glob pattern for image files to search (e.g., "**/*.png", "photos/*.jpg")'), - maxImages: z.number().optional().default(100).describe('Maximum number of images to analyze (default: 100)'), - n: z.number().optional().default(5).describe('Number of top results to return (default: 5)'), - ...globalToolProperties, - }), - input: getOperationInput('image_find'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_find', input }, ctx), - }); - - const imageAttach = ai.tool({ - name: 'image_attach', - description: 'Attach an image to the chat for the user & AI assistant to see', - instructions: `Use this to attach an image file to the chat conversation. The image will be added as a user message and displayed in the chat. Accepts both absolute and relative paths. - -Example: Attach an image file: -{ "path": "images/diagram.png" } - -{{modeInstructions}}`, - schema: z.object({ - path: z.string().describe('Path to the image file to attach (absolute or relative)'), - ...globalToolProperties, - }), - input: getOperationInput('image_attach'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx), - }); - - // Chart display schemas - one for each chart group - const ChartDataPointSchema = z.object({ - name: z.string().describe('Name/label for the data point'), - value: z.number().describe('Numeric value for the data point'), - }); - - 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.record( - z.enum(['pie', 'donut', 'treemap', 'sunburst']), - z.record(z.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['pie', 'donut', 'treemap', 'sunburst']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['bar', 'horizontalBar', 'pictorialBar']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['line', 'area', 'step', 'smoothLine']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['histogram', 'boxplot']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['scatter', 'effectScatter', 'heatmap']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['orderedBar', 'horizontalOrderedBar']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['treemap', 'sunburst', 'tree']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['sankey', 'funnel']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['map']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.unknown()) - ).optional().describe('Optional variant-specific ECharts options'), - defaultVariant: z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']).optional().describe('Default variant to display'), - ...globalToolProperties, - }); - - 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.discriminatedUnion('chartGroup', [ - PartToWholeChartSchema, - CategoryComparisonChartSchema, - TimeSeriesChartSchema, - DistributionChartSchema, - CorrelationChartSchema, - RankingChartSchema, - HierarchicalChartSchema, - FlowChartSchema, - GeospatialChartSchema, - MultivariateComparisonChartSchema, - ]), - metadata: { onlyClient: 'browser' }, - input: getOperationInput('chart_display'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'chart_display', input }, ctx), - }); - - return [ - imageGenerate, - imageEdit, - imageAnalyze, - imageDescribe, - imageFind, - imageAttach, - chartDisplay, - ] as [ - typeof imageGenerate, - typeof imageEdit, - typeof imageAnalyze, - typeof imageDescribe, - typeof imageFind, - typeof imageAttach, - typeof chartDisplay, - ]; -} diff --git a/packages/cletus/src/tools/artist_new.ts b/packages/cletus/src/tools/artist_new.ts deleted file mode 100644 index 7800c88..0000000 --- a/packages/cletus/src/tools/artist_new.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { z } from 'zod'; -import { globalToolProperties, type CletusAI } from '../ai'; -import { getOperationInput } from '../operations/types'; - -/** - * Create artist tools for image operations - * Images are stored in .cletus/images/ and referenced via [filename](filepath) syntax -*/ -export function createArtistTools(ai: CletusAI) { - const imageGenerate = ai.tool({ - name: 'image_generate', - description: 'Generate one or more images from a text prompt', - instructions: `Use this to create new images. Generated images are saved to .cletus/images/ and returned as file paths. - -Example: Generate a landscape image: -{ "prompt": "A serene mountain landscape at sunset with a lake in the foreground", "n": 1 } - -{{modeInstructions}}`, - schema: z.object({ - prompt: z.string().describe('Text description of the image to generate'), - n: z.number().optional().default(1).describe('Number of images to generate (default: 1)'), - ...globalToolProperties, - }), - input: getOperationInput('image_generate'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_generate', input }, ctx), - }); - - const imageEdit = ai.tool({ - name: 'image_edit', - description: 'Edit an existing image based on a text prompt', - instructions: `Use this to modify an existing image. Provide a file path (relative or absolute path or[filename](filepath)) and a description of the edit. The edited image will be saved as a new file. - -Example: Add a sunset effect to an image: -{ "prompt": "Add warm sunset colors and lighting", "path": "images/photo.jpg" } - -{{modeInstructions}}`, - schema: z.object({ - prompt: z.string().describe('Description of how to edit the image'), - path: z.string().describe('Path to the image to edit (relative path or absolute path or [filename](filepath) URL)'), - ...globalToolProperties, - }), - input: getOperationInput('image_edit'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_edit', input }, ctx), - }); - - const imageAnalyze = ai.tool({ - name: 'image_analyze', - description: 'Analyze one or more images with AI and answer questions', - instructions: `Use this to understand what's in images or answer specific questions about them. Can analyze multiple images together for comparison. - -Example: Compare two designs: -{ "prompt": "What are the main differences between these two UI designs?", "paths": ["designs/v1.png", "designs/v2.png"] } - -{{modeInstructions}}`, - schema: z.object({ - prompt: z.string().describe('Question or analysis request about the images'), - paths: z.array(z.string()).describe('Paths to images to analyze (relative paths or absolute file or [filename](filepath))'), - maxCharacters: z.number().optional().default(2084).describe('Maximum response length (maxCharacters/4 = maxTokens, default: 2084)'), - ...globalToolProperties, - }), - input: getOperationInput('image_analyze'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_analyze', input }, ctx), - }); - - const imageDescribe = ai.tool({ - name: 'image_describe', - description: 'Get a detailed description of what\'s in an image', - instructions: `Use this to generate a comprehensive description of an image's contents without a specific question. - -Example: Describe a screenshot: -{ "path": "screenshots/dashboard.png" } - -{{modeInstructions}}`, - schema: z.object({ - path: z.string().describe('Path to the image to describe (relative path or absolute path or [filename](filepath))'), - ...globalToolProperties, - }), - input: getOperationInput('image_describe'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_describe', input }, ctx), - }); - - const imageFind = ai.tool({ - name: 'image_find', - description: 'Find images matching a description using semantic search', - instructions: `Use this to search for images in a directory that match a text description. Each image is analyzed and embedded, then compared to the prompt embedding for similarity scoring. This operation can be slow for large numbers of images. - -Example: Find images of people in photos: -{ "query": "photos containing people smiling", "glob": "photos/**/*.jpg", "n": 5 } - -{{modeInstructions}}`, - schema: z.object({ - query: z.string().describe('Description of what to find in images'), - glob: z.string().describe('Glob pattern for image files to search (e.g., "**/*.png", "photos/*.jpg")'), - maxImages: z.number().optional().default(100).describe('Maximum number of images to analyze (default: 100)'), - n: z.number().optional().default(5).describe('Number of top results to return (default: 5)'), - ...globalToolProperties, - }), - input: getOperationInput('image_find'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_find', input }, ctx), - }); - - const imageAttach = ai.tool({ - name: 'image_attach', - description: 'Attach an image to the chat for the user & AI assistant to see', - instructions: `Use this to attach an image file to the chat conversation. The image will be added as a user message and displayed in the chat. Accepts both absolute and relative paths. - -Example: Attach an image file: -{ "path": "images/diagram.png" } - -{{modeInstructions}}`, - schema: z.object({ - path: z.string().describe('Path to the image file to attach (absolute or relative)'), - ...globalToolProperties, - }), - input: getOperationInput('image_attach'), - call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx), - }); From 53617c8454b4ae193d7a614a8a21054ebdf16781 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Sat, 20 Dec 2025 11:30:19 -0500 Subject: [PATCH 09/12] Refactor chart types and schemas to helpers/artist Moved chart-related types and schemas from tools/artist_schemas.ts and operations/artist.tsx to helpers/artist.ts for better organization and reuse. Updated imports across affected files. Also removed unused @types/echarts and @types/zrender dependencies from package.json and package-lock.json. --- package-lock.json | 18 ---- packages/cletus/package.json | 1 - packages/cletus/src/agents/chat-agent.ts | 1 - .../cletus/src/browser/operations/artist.tsx | 15 ++-- .../artist_schemas.ts => helpers/artist.ts} | 87 ++++++++++++++++--- packages/cletus/src/operations/artist.tsx | 75 ++-------------- packages/cletus/src/tools/ABOUT.md | 2 +- packages/cletus/src/tools/artist.ts | 2 +- 8 files changed, 92 insertions(+), 109 deletions(-) rename packages/cletus/src/{tools/artist_schemas.ts => helpers/artist.ts} (71%) diff --git a/package-lock.json b/package-lock.json index 3380b81..5404b4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5739,16 +5739,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/echarts": { - "version": "4.9.22", - "resolved": "https://registry.npmjs.org/@types/echarts/-/echarts-4.9.22.tgz", - "integrity": "sha512-7Fo6XdWpoi8jxkwP7BARUOM7riq8bMhmsCtSG8gzUcJmFhLo387tihoBYS/y5j7jl3PENT5RxeWZdN9RiwO7HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/zrender": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5976,13 +5966,6 @@ "@types/node": "*" } }, - "node_modules/@types/zrender": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@types/zrender/-/zrender-4.0.6.tgz", - "integrity": "sha512-1jZ9bJn2BsfmYFPBHtl5o3uV+ILejAtGrDcYSpT4qaVKEI/0YY+arw3XHU04Ebd8Nca3SQ7uNcLaqiL+tTFVMg==", - "dev": true, - "license": "MIT" - }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -16986,7 +16969,6 @@ "@tailwindcss/typography": "^0.5.19", "@tavily/core": "^0.5.13", "@types/diff": "^7.0.2", - "@types/echarts": "^4.9.22", "@types/file-type": "^10.6.0", "@types/jest": "^29.5.14", "@types/node": "^24.9.1", diff --git a/packages/cletus/package.json b/packages/cletus/package.json index 477dadd..24effaf 100644 --- a/packages/cletus/package.json +++ b/packages/cletus/package.json @@ -75,7 +75,6 @@ "@tailwindcss/typography": "^0.5.19", "@tavily/core": "^0.5.13", "@types/diff": "^7.0.2", - "@types/echarts": "^4.9.22", "@types/file-type": "^10.6.0", "@types/jest": "^29.5.14", "@types/node": "^24.9.1", 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/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index 7a8d113..bc33d92 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -1,10 +1,11 @@ -import React, { useRef, useState, useEffect } from 'react'; -import { abbreviate, deepMerge, isObject, pluralize } from '../../shared'; -import { createRenderer } from './render'; -import { ClickableImage } from '../components/ImageViewer'; import * as echarts from 'echarts'; -import type { ChartVariant, EChartsOption } from '../../operations/artist'; -import type { ChartDataPoint } from '../../tools/artist_schemas'; +import React, { useEffect, useRef, useState } 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'; + const renderer = createRenderer({ borderColor: "border-neon-pink/30", @@ -377,7 +378,7 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va // ============================================================================ export const chart_display = renderer<'chart_display'>( - (op) => `ChartDisplay(${op.input.chartGroup}, ${op.input.data?.length || 0} points)`, + (op) => `ChartDisplay(${op.input.chart.chartGroup}, ${op.input.chart.data?.length || 0} points)`, (op): string | React.ReactNode | null => { if (op.output) { return ( diff --git a/packages/cletus/src/tools/artist_schemas.ts b/packages/cletus/src/helpers/artist.ts similarity index 71% rename from packages/cletus/src/tools/artist_schemas.ts rename to packages/cletus/src/helpers/artist.ts index 4b9830a..270e144 100644 --- a/packages/cletus/src/tools/artist_schemas.ts +++ b/packages/cletus/src/helpers/artist.ts @@ -1,3 +1,5 @@ + +import { EChartsOption } from 'echarts'; import { z } from 'zod'; // ============================================================================ @@ -17,7 +19,7 @@ export const PartToWholeChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of data points showing parts of a whole'), variantOptions: z.record( z.enum(['pie', 'donut', 'treemap', 'sunburst']), - z.record(z.unknown()) + z.record(z.string(), z.unknown()) ).optional().describe('Optional variant-specific ECharts options'), defaultVariant: z.enum(['pie', 'donut', 'treemap', 'sunburst']).optional().describe('Default variant to display'), }); @@ -28,7 +30,7 @@ export const CategoryComparisonChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of data points for category comparison'), variantOptions: z.record( z.enum(['bar', 'horizontalBar', 'pictorialBar']), - z.record(z.unknown()) + 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'), }); @@ -39,7 +41,7 @@ export const TimeSeriesChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of time-series data points'), variantOptions: z.record( z.enum(['line', 'area', 'step', 'smoothLine']), - z.record(z.unknown()) + 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'), }); @@ -50,7 +52,7 @@ export const DistributionChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of data points for distribution analysis'), variantOptions: z.record( z.enum(['histogram', 'boxplot']), - z.record(z.unknown()) + z.record(z.string(), z.unknown()) ).optional().describe('Optional variant-specific ECharts options'), defaultVariant: z.enum(['histogram', 'boxplot']).optional().describe('Default variant to display'), }); @@ -61,7 +63,7 @@ export const CorrelationChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of data points for correlation analysis'), variantOptions: z.record( z.enum(['scatter', 'effectScatter', 'heatmap']), - z.record(z.unknown()) + 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'), }); @@ -72,7 +74,7 @@ export const RankingChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of data points to rank'), variantOptions: z.record( z.enum(['orderedBar', 'horizontalOrderedBar']), - z.record(z.unknown()) + z.record(z.string(), z.unknown()) ).optional().describe('Optional variant-specific ECharts options'), defaultVariant: z.enum(['orderedBar', 'horizontalOrderedBar']).optional().describe('Default variant to display'), }); @@ -83,7 +85,7 @@ export const HierarchicalChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of hierarchical data points'), variantOptions: z.record( z.enum(['treemap', 'sunburst', 'tree']), - z.record(z.unknown()) + 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'), }); @@ -94,7 +96,7 @@ export const FlowChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of flow/funnel data points'), variantOptions: z.record( z.enum(['sankey', 'funnel']), - z.record(z.unknown()) + z.record(z.string(), z.unknown()) ).optional().describe('Optional variant-specific ECharts options'), defaultVariant: z.enum(['sankey', 'funnel']).optional().describe('Default variant to display'), }); @@ -105,7 +107,7 @@ export const GeospatialChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of geographic data points'), variantOptions: z.record( z.enum(['map']), - z.record(z.unknown()) + z.record(z.string(), z.unknown()) ).optional().describe('Optional variant-specific ECharts options'), defaultVariant: z.enum(['map']).optional().describe('Default variant to display'), }); @@ -116,7 +118,7 @@ export const MultivariateComparisonChartSchema = z.object({ data: z.array(ChartDataPointSchema).describe('Array of data points for multivariate comparison'), variantOptions: z.record( z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']), - z.record(z.unknown()) + 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'), }); @@ -136,3 +138,68 @@ export const ChartConfigSchema = z.union([ ]); 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/artist.tsx b/packages/cletus/src/operations/artist.tsx index c7c2118..c4c0382 100644 --- a/packages/cletus/src/operations/artist.tsx +++ b/packages/cletus/src/operations/artist.tsx @@ -8,7 +8,9 @@ import { getImagePath } from "../file-manager"; import { fileIsReadable, searchFiles } from "../helpers/files"; import { renderOperation } from "../helpers/render"; import { operationOf } from "./types"; -import type { ChartConfig, ChartDataPoint } from "../tools/artist_schemas"; +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(/^\[([^\]]+)\]\(([^)]+)\)$/) || []; @@ -481,73 +483,6 @@ export const image_attach = operationOf< ), }); -// ============================================================================ -// 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'], -}; - -// ECharts option type (simplified - in reality it's much more complex) -export type EChartsOption = Record; - -export type ChartDisplayInput = { - chart: ChartConfig; -}; - -export type ChartDisplayOutput = { - chartGroup: ChartGroup; - availableVariants: ChartVariant[]; - currentVariant: ChartVariant; - option: EChartsOption; - data: ChartDataPoint[]; - variantOptions: Partial>>; -}; - // ============================================================================ // Chart Display Operation // ============================================================================ @@ -570,7 +505,7 @@ export const chart_display = operationOf< const { chartGroup, data, title, variantOptions, defaultVariant } = input.chart; const availableVariants = ChartGroupVariants[chartGroup]; const currentVariant = defaultVariant || availableVariants[0]; - const variantOpts = variantOptions || {}; + const variantOpts = (variantOptions || {}) as Partial>>; // Build base option from input const baseOption: EChartsOption = { @@ -751,7 +686,7 @@ function applyVariantToOption( case 'parallel': // Use the variant name directly as ECharts type (these are already correct) option.series = [{ - type: variant, + type: variant as any, data, }]; break; 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 d3ab37c..b066ff8 100644 --- a/packages/cletus/src/tools/artist.ts +++ b/packages/cletus/src/tools/artist.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { globalToolProperties, type CletusAI } from '../ai'; import { getOperationInput } from '../operations/types'; -import { ChartConfigSchema } from './artist_schemas'; +import { ChartConfigSchema } from '../helpers/artist'; /** * Create artist tools for image operations From 7484b4113f30ee4675e1fd595fd007cf671c7550 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Sat, 20 Dec 2025 12:07:52 -0500 Subject: [PATCH 10/12] Add math rendering with KaTeX and chart/diagram tweaks Integrates KaTeX and math support in markdown rendering using remark-math and rehype-katex. Updates build to copy KaTeX CSS and inject it into HTML. Refactors chart and diagram operation renderers to support custom summary classes and moves their definitions. Adjusts chart variantOptions schema for stricter typing. Minor UI and prop improvements for message and operation components. --- package-lock.json | 214 +++++++++++++++++- packages/cletus/esbuild.browser.cjs | 19 +- packages/cletus/package.json | 3 + .../src/browser/components/MessageItem.tsx | 25 +- .../src/browser/components/MessageList.tsx | 3 +- .../browser/components/OperationDisplay.tsx | 4 +- .../cletus/src/browser/operations/artist.tsx | 93 ++++---- packages/cletus/src/helpers/artist.ts | 10 +- packages/cletus/src/tools/artist.ts | 1 + 9 files changed, 314 insertions(+), 58 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09663cb..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", @@ -11054,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", @@ -11074,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", @@ -11182,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", @@ -12957,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" @@ -12974,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" @@ -13764,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", @@ -14139,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", @@ -15857,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", @@ -15890,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", @@ -17404,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", @@ -17430,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", @@ -18329,12 +18536,15 @@ "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" }, 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 3ae8509..81dcf75 100644 --- a/packages/cletus/package.json +++ b/packages/cletus/package.json @@ -48,12 +48,15 @@ "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" }, diff --git a/packages/cletus/src/browser/components/MessageItem.tsx b/packages/cletus/src/browser/components/MessageItem.tsx index 904f73b..160a6b6 100644 --- a/packages/cletus/src/browser/components/MessageItem.tsx +++ b/packages/cletus/src/browser/components/MessageItem.tsx @@ -2,6 +2,8 @@ import React 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'; @@ -15,8 +17,21 @@ 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; +}; + export const MessageItem: React.FC = ({ message, operationDecisions, @@ -24,6 +39,7 @@ export const MessageItem: React.FC = ({ onApproveOperation, onRejectOperation, hasMultiplePendingOps = false, + isProcessing = false, }) => { const { role, name, content, operations = [] } = message; @@ -79,11 +95,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) { @@ -122,7 +138,8 @@ export const MessageItem: React.FC = ({ )} >

{children}

, ul: ({ children }) =>
    {children}
, @@ -154,7 +171,7 @@ export const MessageItem: React.FC = ({ }, }} > - {item.content} + {preprocessLatex(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 cb584e5..fd79f60 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -40,7 +40,8 @@ export const image_generate = renderer<'image_generate'>( return `Generated ${pluralize(count, 'image')}`; } return null; - } + }, + () => ({ summaryClasses: '' }) ); export const image_edit = renderer<'image_edit'>( @@ -63,7 +64,8 @@ export const image_edit = renderer<'image_edit'>( return 'Edited image saved'; } return null; - } + }, + () => ({ summaryClasses: '' }) ); export const image_analyze = renderer<'image_analyze'>( @@ -113,6 +115,50 @@ 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 => { + if (op.output?.spec) { + return ( +
+
Mermaid Diagram
+ +
+ ); + } + return null; + }, + () => ({ summaryClasses: '' }) +); + + // ============================================================================ // Chart Display Component // ============================================================================ @@ -164,7 +210,7 @@ const ChartDisplay: React.FC<{ return (
- Variant: + Type:
{availableVariants.map((variant) => (
); }) diff --git a/packages/cletus/src/browser/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index fd79f60..3f460a1 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -175,6 +175,13 @@ const ChartDisplay: React.FC<{ const chartInstanceRef = useRef(null); const [currentVariant, setCurrentVariant] = useState(initialVariant); + // Extract global options (title, etc.) from initial option to preserve across variants + const globalOptions = useRef>({ + title: initialOption.title, + grid: initialOption.grid, + backgroundColor: initialOption.backgroundColor, + }); + // Initialize chart useEffect(() => { if (!chartRef.current) return; @@ -199,7 +206,9 @@ const ChartDisplay: React.FC<{ useEffect(() => { if (!chartInstanceRef.current) return; - const newOption = buildOptionForVariant(currentVariant, data, variantOptions[currentVariant] || {}); + const variantSpecificOption = buildOptionForVariant(currentVariant, data, variantOptions[currentVariant] || {}); + // Merge global options (title, etc.) with variant-specific options + const newOption = deepMerge(variantSpecificOption, globalOptions.current); chartInstanceRef.current.setOption(newOption, true); }, [currentVariant, data, variantOptions]); 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(''); + } + }} + /> +
+ + +
+
+
+ )}
); }; From 538ae5e5f57538f7e1e1b388d3e1a8ccbdd5a930 Mon Sep 17 00:00:00 2001 From: Philip Diffenderfer Date: Sat, 20 Dec 2025 15:29:32 -0500 Subject: [PATCH 12/12] Add ChartViewer component and improve chart theming Introduces a new ChartViewer component for fullscreen and interactive chart viewing, including variant toggling and download functionality. Refactors ChartDisplay in artist.tsx to use ClickableChart, improving code reuse and user experience. Enhances ECharts theming for dark mode with consistent axis and tooltip styling. Updates DiagramViewer layout for better centering and responsiveness. --- .../src/browser/components/ChartViewer.tsx | 210 ++++++++++++++++++ .../src/browser/components/DiagramViewer.tsx | 2 +- .../cletus/src/browser/operations/artist.tsx | 115 +++++----- 3 files changed, 269 insertions(+), 58 deletions(-) create mode 100644 packages/cletus/src/browser/components/ChartViewer.tsx 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/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx index 3f460a1..cc44644 100644 --- a/packages/cletus/src/browser/operations/artist.tsx +++ b/packages/cletus/src/browser/operations/artist.tsx @@ -1,11 +1,12 @@ import * as echarts from 'echarts'; -import React, { useEffect, useRef, useState } from 'react'; +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({ @@ -171,46 +172,21 @@ const ChartDisplay: React.FC<{ data: ChartDataPoint[]; variantOptions: Partial>>; }> = ({ chartGroup, availableVariants, currentVariant: initialVariant, option: initialOption, data, variantOptions }) => { - const chartRef = useRef(null); - const chartInstanceRef = useRef(null); const [currentVariant, setCurrentVariant] = useState(initialVariant); // Extract global options (title, etc.) from initial option to preserve across variants - const globalOptions = useRef>({ + const globalOptions = useMemo>(() => ({ title: initialOption.title, grid: initialOption.grid, backgroundColor: initialOption.backgroundColor, - }); - - // Initialize chart - useEffect(() => { - if (!chartRef.current) return; - - const chart = echarts.init(chartRef.current); - chartInstanceRef.current = chart; - - // Set initial option - chart.setOption(initialOption); - - // Handle resize - const handleResize = () => chart.resize(); - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - chart.dispose(); - }; - }, []); - - // Update chart when variant changes - useEffect(() => { - if (!chartInstanceRef.current) return; + }), [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 - const newOption = deepMerge(variantSpecificOption, globalOptions.current); - chartInstanceRef.current.setOption(newOption, true); - }, [currentVariant, data, variantOptions]); + return deepMerge(variantSpecificOption, globalOptions); + }, [currentVariant, data, variantOptions, globalOptions]); const handleVariantChange = (variant: ChartVariant) => { setCurrentVariant(variant); @@ -236,10 +212,13 @@ const ChartDisplay: React.FC<{ ))}
-
); @@ -256,9 +235,31 @@ const ChartDisplay: React.FC<{ * 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 = { - tooltip: { trigger: 'item' }, - legend: {}, + 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: [], }; @@ -310,8 +311,8 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va break; case 'bar': - baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; - baseOption.yAxis = { type: 'value' }; + 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), @@ -319,8 +320,8 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va break; case 'horizontalBar': - baseOption.yAxis = { type: 'category', data: data.map((d: any) => d.name) }; - baseOption.xAxis = { type: 'value' }; + 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), @@ -328,8 +329,8 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va break; case 'pictorialBar': - baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; - baseOption.yAxis = { type: 'value' }; + 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), @@ -338,8 +339,8 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va break; case 'line': - baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; - baseOption.yAxis = { type: 'value' }; + 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), @@ -347,8 +348,8 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va break; case 'area': - baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; - baseOption.yAxis = { type: 'value' }; + 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), @@ -357,8 +358,8 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va break; case 'step': - baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; - baseOption.yAxis = { type: 'value' }; + 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), @@ -367,8 +368,8 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va break; case 'smoothLine': - baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name) }; - baseOption.yAxis = { type: 'value' }; + 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), @@ -399,24 +400,24 @@ function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], va // 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) }; - baseOption.xAxis = { type: 'value' }; + 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) }; - baseOption.yAxis = { type: 'value' }; + 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) }; - baseOption.yAxis = { type: 'value' }; + 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),