1 ? 'cursor-grab' : 'cursor-default'
)}
onWheel={handleWheel}
diff --git a/packages/cletus/src/browser/components/MessageItem.tsx b/packages/cletus/src/browser/components/MessageItem.tsx
index 904f73b..4c75373 100644
--- a/packages/cletus/src/browser/components/MessageItem.tsx
+++ b/packages/cletus/src/browser/components/MessageItem.tsx
@@ -1,12 +1,15 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { User, Bot, Info } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
+import remarkMath from 'remark-math';
+import rehypeKatex from 'rehype-katex';
import { OperationDisplay } from '../operations';
import { TypingIndicator } from './TypingIndicator';
import { cn } from '../lib/utils';
import { ClickableImage } from './ImageViewer';
import type { Message } from '../../schemas';
+import type { Components } from 'react-markdown';
interface MessageItemProps {
message: Message;
@@ -15,8 +18,68 @@ interface MessageItemProps {
onApproveOperation: (message: Message, idx: number) => void;
onRejectOperation: (message: Message, idx: number) => void;
hasMultiplePendingOps?: boolean;
+ isProcessing?: boolean;
}
+// Preprocess content to convert LaTeX delimiters to markdown math delimiters
+const preprocessLatex = (text: string): string => {
+ // Convert \[...\] to $$...$$ (display math)
+ text = text.replace(/\\\[([\s\S]*?)\\\]/g, (match, content) => `$$${content}$$`);
+ // Convert \(...\) to $...$ (inline math)
+ text = text.replace(/\\\(([\s\S]*?)\\\)/g, (match, content) => `$${content}$`);
+ // Convert standalone [... ] patterns that look like display math (with array/equation content)
+ // Only convert if it contains LaTeX commands like \begin, \text, etc.
+ text = text.replace(/\[\s*(\\begin|\\text|\\frac|\\int|\\sum|\\prod)([\s\S]*?)\s*\]/g, (match, cmd, rest) => `$$${cmd}${rest}$$`);
+ return text;
+};
+
+// Stable markdown components reference - created once and reused
+const markdownComponents: Components = {
+ p: ({ children }) =>
{children}
,
+ ul: ({ children }) =>
,
+ ol: ({ children }) =>
{children}
,
+ li: ({ children }) =>
{children},
+ code: ({ inline, children, ...props }: any) => {
+ return inline ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ );
+ },
+ pre: ({ children }) =>
{children},
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ img: ({ src, alt, ...props }: any) => {
+ // Transform local file paths to use the /file route
+ const imageSrc = src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:')
+ ? `/file?path=${encodeURIComponent(src)}`
+ : src;
+ return
;
+ },
+};
+
+// Memoized markdown content renderer
+const MarkdownContent = React.memo<{ content: string }>(({ content }) => {
+ const processedContent = useMemo(() => preprocessLatex(content), [content]);
+
+ return (
+
+ {processedContent}
+
+ );
+});
+
export const MessageItem: React.FC
= ({
message,
operationDecisions,
@@ -24,6 +87,7 @@ export const MessageItem: React.FC = ({
onApproveOperation,
onRejectOperation,
hasMultiplePendingOps = false,
+ isProcessing = false,
}) => {
const { role, name, content, operations = [] } = message;
@@ -79,11 +143,11 @@ export const MessageItem: React.FC = ({
{/* Content and Operations in order */}
- {visibleContent.length === 0 && isAssistant ? (
+ {visibleContent.length === 0 && isAssistant && isProcessing ? (
- ) : (
+ ) : visibleContent.length === 0 && isAssistant ? null : (
visibleContent.map((item, index) => {
// Render operation if this content item has an operation
if (item.operation && item.operationIndex !== undefined) {
@@ -121,41 +185,7 @@ export const MessageItem: React.FC
= ({
isSystem && 'bg-muted/50 border border-muted italic'
)}
>
- {children}
,
- ul: ({ children }) => ,
- ol: ({ children }) => {children}
,
- li: ({ children }) => {children},
- code: ({ inline, children, ...props }: any) => {
- return inline ? (
-
- {children}
-
- ) : (
-
- {children}
-
- );
- },
- pre: ({ children }) => {children},
- a: ({ href, children }) => (
-
- {children}
-
- ),
- img: ({ src, alt, ...props }: any) => {
- // Transform local file paths to use the /file route
- const imageSrc = src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:')
- ? `/file?path=${encodeURIComponent(src)}`
- : src;
- return ;
- },
- }}
- >
- {item.content}
-
+
);
})
diff --git a/packages/cletus/src/browser/components/MessageList.tsx b/packages/cletus/src/browser/components/MessageList.tsx
index 28eeb2a..bc066fc 100644
--- a/packages/cletus/src/browser/components/MessageList.tsx
+++ b/packages/cletus/src/browser/components/MessageList.tsx
@@ -44,7 +44,7 @@ export const MessageList: React.FC = ({
{messages.map((message, index) => {
// Check if this is the last message with pending operations
- // const isLastMessage = index === messages.length - 1;
+ const isLastMessage = index === messages.length - 1;
// const hasPendingOps = message.operations?.some(op => op.status === 'analyzed');
const pendingOpCount = message.operations?.filter(op => op.status === 'analyzed' || op.status === 'doing').length || 0;
@@ -57,6 +57,7 @@ export const MessageList: React.FC
= ({
onApproveOperation={onApproveOperation}
onRejectOperation={onRejectOperation}
hasMultiplePendingOps={pendingOpCount > 1}
+ isProcessing={isLastMessage && loading}
/>
);
})}
diff --git a/packages/cletus/src/browser/components/OperationDisplay.tsx b/packages/cletus/src/browser/components/OperationDisplay.tsx
index f49dcf1..ac84dea 100644
--- a/packages/cletus/src/browser/components/OperationDisplay.tsx
+++ b/packages/cletus/src/browser/components/OperationDisplay.tsx
@@ -370,6 +370,7 @@ export interface OperationDisplayProps {
operation: Operation;
label: string;
summary?: React.ReactNode | string | null;
+ summaryClasses?: string;
borderColor?: string;
bgColor?: string;
labelColor?: string;
@@ -390,6 +391,7 @@ export const OperationDisplay: React.FC = ({
label,
summary,
borderColor = 'border-border',
+ summaryClasses = 'max-h-[8rem] overflow-y-auto',
bgColor = 'bg-card/50',
labelColor = 'text-foreground',
message,
@@ -425,7 +427,7 @@ export const OperationDisplay: React.FC = ({
{/* Summary */}
{displaySummary && (
-
+
{typeof displaySummary === 'string' ? (
<>
→
diff --git a/packages/cletus/src/browser/operations/artist.tsx b/packages/cletus/src/browser/operations/artist.tsx
index e1c40bb..cc44644 100644
--- a/packages/cletus/src/browser/operations/artist.tsx
+++ b/packages/cletus/src/browser/operations/artist.tsx
@@ -1,8 +1,13 @@
-import React from 'react';
-import { abbreviate, pluralize } from '../../shared';
-import { createRenderer } from './render';
+import * as echarts from 'echarts';
+import React, { useEffect, useRef, useState, useMemo } from 'react';
+import { abbreviate, deepMerge, pluralize } from '../../shared';
import { ClickableImage } from '../components/ImageViewer';
+import { createRenderer } from './render';
+import { ChartDataPoint, ChartVariant } from '../../helpers/artist';
+import { type EChartsOption } from 'echarts';
import { ClickableDiagram } from '../components/DiagramViewer';
+import { ClickableChart } from '../components/ChartViewer';
+
const renderer = createRenderer({
borderColor: "border-neon-pink/30",
@@ -36,7 +41,8 @@ export const image_generate = renderer<'image_generate'>(
return `Generated ${pluralize(count, 'image')}`;
}
return null;
- }
+ },
+ () => ({ summaryClasses: '' })
);
export const image_edit = renderer<'image_edit'>(
@@ -59,7 +65,8 @@ export const image_edit = renderer<'image_edit'>(
return 'Edited image saved';
}
return null;
- }
+ },
+ () => ({ summaryClasses: '' })
);
export const image_analyze = renderer<'image_analyze'>(
@@ -109,6 +116,30 @@ export const image_attach = renderer<'image_attach'>(
}
);
+// ============================================================================
+// Chart Display Renderer
+// ============================================================================
+
+export const chart_display = renderer<'chart_display'>(
+ (op) => `ChartDisplay(${op.input.chart.chartGroup}, ${op.input.chart.data?.length || 0} points)`,
+ (op): string | React.ReactNode | null => {
+ if (op.output) {
+ return (
+
+ );
+ }
+ return null;
+ },
+ () => ({ summaryClasses: '' })
+);
+
export const diagram_show = renderer<'diagram_show'>(
(op) => `DiagramShow()`,
(op): string | React.ReactNode | null => {
@@ -124,5 +155,277 @@ export const diagram_show = renderer<'diagram_show'>(
);
}
return null;
- }
+ },
+ () => ({ summaryClasses: '' })
);
+
+
+// ============================================================================
+// Chart Display Component
+// ============================================================================
+
+const ChartDisplay: React.FC<{
+ chartGroup: string;
+ availableVariants: ChartVariant[];
+ currentVariant: ChartVariant;
+ option: EChartsOption;
+ data: ChartDataPoint[];
+ variantOptions: Partial
>>;
+}> = ({ chartGroup, availableVariants, currentVariant: initialVariant, option: initialOption, data, variantOptions }) => {
+ const [currentVariant, setCurrentVariant] = useState(initialVariant);
+
+ // Extract global options (title, etc.) from initial option to preserve across variants
+ const globalOptions = useMemo>(() => ({
+ title: initialOption.title,
+ grid: initialOption.grid,
+ backgroundColor: initialOption.backgroundColor,
+ }), [initialOption.title, initialOption.grid, initialOption.backgroundColor]);
+
+ // Compute current option based on selected variant
+ const currentOption = useMemo(() => {
+ const variantSpecificOption = buildOptionForVariant(currentVariant, data, variantOptions[currentVariant] || {});
+ // Merge global options (title, etc.) with variant-specific options
+ return deepMerge(variantSpecificOption, globalOptions);
+ }, [currentVariant, data, variantOptions, globalOptions]);
+
+ const handleVariantChange = (variant: ChartVariant) => {
+ setCurrentVariant(variant);
+ };
+
+ return (
+
+
+
Type:
+
+ {availableVariants.map((variant) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+/**
+ * Build ECharts option for a specific variant
+ *
+ * Note: This function is duplicated from operations/artist.tsx because:
+ * 1. The browser code cannot import from Node.js-specific files
+ * 2. Extracting to shared.ts would require moving all chart logic there
+ * 3. The logic needs to be in sync for server-side and client-side rendering
+ *
+ * If making changes here, ensure the same changes are made in operations/artist.tsx
+ */
+function buildOptionForVariant(variant: ChartVariant, data: ChartDataPoint[], variantOption: Partial): EChartsOption {
+ // Dark mode axis styling
+ const axisStyle = {
+ axisLine: { lineStyle: { color: '#666' } },
+ axisLabel: { color: '#ffffff' },
+ splitLine: { lineStyle: { color: '#333' } },
+ };
+
+ const baseOption: EChartsOption = {
+ backgroundColor: 'transparent',
+ textStyle: {
+ color: '#ffffff',
+ },
+ tooltip: {
+ trigger: 'item',
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
+ borderColor: '#666',
+ textStyle: {
+ color: '#ffffff',
+ },
+ },
+ legend: {
+ textStyle: {
+ color: '#ffffff',
+ },
+ },
+ series: [],
+ };
+
+ // Apply variant-specific series configuration
+ switch (variant) {
+ case 'pie':
+ baseOption.series = [{
+ type: 'pie',
+ radius: '50%',
+ data,
+ emphasis: {
+ itemStyle: {
+ shadowBlur: 10,
+ shadowOffsetX: 0,
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
+ },
+ },
+ }];
+ break;
+
+ case 'donut':
+ baseOption.series = [{
+ type: 'pie',
+ radius: ['40%', '70%'],
+ data,
+ emphasis: {
+ itemStyle: {
+ shadowBlur: 10,
+ shadowOffsetX: 0,
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
+ },
+ },
+ }];
+ break;
+
+ case 'treemap':
+ baseOption.series = [{
+ type: 'treemap',
+ data,
+ }];
+ break;
+
+ case 'sunburst':
+ baseOption.series = [{
+ type: 'sunburst',
+ data,
+ radius: [0, '90%'],
+ }];
+ break;
+
+ case 'bar':
+ baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle };
+ baseOption.yAxis = { type: 'value', ...axisStyle };
+ baseOption.series = [{
+ type: 'bar',
+ data: data.map((d: any) => d.value),
+ }];
+ break;
+
+ case 'horizontalBar':
+ baseOption.yAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle };
+ baseOption.xAxis = { type: 'value', ...axisStyle };
+ baseOption.series = [{
+ type: 'bar',
+ data: data.map((d: any) => d.value),
+ }];
+ break;
+
+ case 'pictorialBar':
+ baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle };
+ baseOption.yAxis = { type: 'value', ...axisStyle };
+ baseOption.series = [{
+ type: 'pictorialBar',
+ data: data.map((d: any) => d.value),
+ symbol: 'rect',
+ }];
+ break;
+
+ case 'line':
+ baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle };
+ baseOption.yAxis = { type: 'value', ...axisStyle };
+ baseOption.series = [{
+ type: 'line',
+ data: data.map((d: any) => d.value),
+ }];
+ break;
+
+ case 'area':
+ baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle };
+ baseOption.yAxis = { type: 'value', ...axisStyle };
+ baseOption.series = [{
+ type: 'line',
+ data: data.map((d: any) => d.value),
+ areaStyle: {},
+ }];
+ break;
+
+ case 'step':
+ baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle };
+ baseOption.yAxis = { type: 'value', ...axisStyle };
+ baseOption.series = [{
+ type: 'line',
+ data: data.map((d: any) => d.value),
+ step: 'start',
+ }];
+ break;
+
+ case 'smoothLine':
+ baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle };
+ baseOption.yAxis = { type: 'value', ...axisStyle };
+ baseOption.series = [{
+ type: 'line',
+ data: data.map((d: any) => d.value),
+ smooth: true,
+ }];
+ break;
+
+ case 'histogram':
+ case 'boxplot':
+ case 'scatter':
+ case 'effectScatter':
+ case 'heatmap':
+ case 'tree':
+ case 'sankey':
+ case 'funnel':
+ case 'map':
+ case 'radar':
+ case 'parallel':
+ // Use the variant name directly as ECharts type (these are already correct)
+ baseOption.series = [{
+ type: variant,
+ data,
+ }];
+ break;
+
+ case 'orderedBar':
+ case 'horizontalOrderedBar':
+ // Ordered bars are just bars with sorted data
+ const sortedData = [...data].sort((a: any, b: any) => b.value - a.value);
+ if (variant === 'horizontalOrderedBar') {
+ baseOption.yAxis = { type: 'category', data: sortedData.map((d: any) => d.name), ...axisStyle };
+ baseOption.xAxis = { type: 'value', ...axisStyle };
+ } else {
+ baseOption.xAxis = { type: 'category', data: sortedData.map((d: any) => d.name), ...axisStyle };
+ baseOption.yAxis = { type: 'value', ...axisStyle };
+ }
+ baseOption.series = [{
+ type: 'bar',
+ data: sortedData.map((d: any) => d.value),
+ }];
+ break;
+
+ case 'groupedBar':
+ case 'stackedBar':
+ // Grouped and stacked bars need multiple series
+ // For now, treat as regular bar - requires more complex data structure
+ baseOption.xAxis = { type: 'category', data: data.map((d: any) => d.name), ...axisStyle };
+ baseOption.yAxis = { type: 'value', ...axisStyle };
+ baseOption.series = [{
+ type: 'bar',
+ data: data.map((d: any) => d.value),
+ stack: variant === 'stackedBar' ? 'total' : undefined,
+ }];
+ break;
+ }
+
+ // Deep merge variant-specific options
+ return deepMerge(baseOption, variantOption);
+}
diff --git a/packages/cletus/src/browser/pages/MainPage.tsx b/packages/cletus/src/browser/pages/MainPage.tsx
index 56d93f4..06a1c35 100644
--- a/packages/cletus/src/browser/pages/MainPage.tsx
+++ b/packages/cletus/src/browser/pages/MainPage.tsx
@@ -38,6 +38,8 @@ export const MainPage: React.FC = ({ config }) => {
const [showProfile, setShowProfile] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearConfirmText, setClearConfirmText] = useState('');
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState('');
const [totalCost, setTotalCost] = useState(0);
@@ -292,6 +294,7 @@ export const MainPage: React.FC = ({ config }) => {
setPendingMessage(null);
setTemporaryUserMessage(null);
setTemporaryAssistantMessage(null);
+ setChatMetaState(null); // Clear cached chat metadata when switching chats
setStatus(''); // Clear any previous error or status messages
setLoading(true);
window.history.pushState({}, '', `/chat/${chatId}`);
@@ -460,7 +463,7 @@ export const MainPage: React.FC = ({ config }) => {
return;
}
send({ type: 'update_chat_meta', data: { chatId: selectedChatId, updates: { title: editedTitle.trim() } } });
- setIsEditingTitle(false);
+ handleCancelEditTitle();
};
const handleCancelEditTitle = () => {
@@ -470,12 +473,17 @@ export const MainPage: React.FC = ({ config }) => {
const handleDeleteChat = () => {
if (!selectedChatId || !chatMeta) return;
- if (confirm(`Are you sure you want to delete "${chatMeta.title}"? This cannot be undone.`)) {
- send({ type: 'delete_chat', data: { chatId: selectedChatId } });
- setSelectedChatId(null);
- setMessages([]);
- setTimeout(() => send({ type: 'get_config' }), 500);
- }
+ setShowDeleteConfirm(true);
+ };
+
+ const handleConfirmDelete = () => {
+ if (!selectedChatId || deleteConfirmText !== 'DELETE') return;
+ send({ type: 'delete_chat', data: { chatId: selectedChatId } });
+ setSelectedChatId(null);
+ setMessages([]);
+ setShowDeleteConfirm(false);
+ setDeleteConfirmText('');
+ setTimeout(() => send({ type: 'get_config' }), 500);
};
const handleOperationApproval = (message: Message, approved: number[], rejected: number[]) => {
@@ -905,6 +913,63 @@ export const MainPage: React.FC = ({ config }) => {
)}
+
+ {showDeleteConfirm && chatMeta && (
+ {
+ setShowDeleteConfirm(false);
+ setDeleteConfirmText('');
+ }}
+ >
+
e.stopPropagation()}
+ >
+
Delete Chat
+
+ Are you sure you want to delete "{chatMeta.title}"? This will permanently delete all messages in this chat. This action cannot be undone.
+
+
+ Type DELETE to confirm:
+
+
setDeleteConfirmText(e.target.value)}
+ placeholder="Type DELETE"
+ className="mb-4 font-mono"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleConfirmDelete();
+ } else if (e.key === 'Escape') {
+ setShowDeleteConfirm(false);
+ setDeleteConfirmText('');
+ }
+ }}
+ />
+
+
+
+
+
+
+ )}
);
};
diff --git a/packages/cletus/src/common.ts b/packages/cletus/src/common.ts
index b5a87c5..fb94943 100644
--- a/packages/cletus/src/common.ts
+++ b/packages/cletus/src/common.ts
@@ -23,6 +23,8 @@ export {
normalizeNewlines,
convertNewlines,
paginateText,
+ deepMerge,
+ isObject,
type NewlineType,
} from './shared';
diff --git a/packages/cletus/src/helpers/artist.ts b/packages/cletus/src/helpers/artist.ts
new file mode 100644
index 0000000..fb92d77
--- /dev/null
+++ b/packages/cletus/src/helpers/artist.ts
@@ -0,0 +1,207 @@
+
+import { EChartsOption } from 'echarts';
+import { z } from 'zod';
+
+// ============================================================================
+// Chart Display Schemas
+// ============================================================================
+
+export const ChartDataPointSchema = z.object({
+ name: z.string().describe('Name/label for the data point'),
+ value: z.number().describe('Numeric value for the data point'),
+});
+
+export type ChartDataPoint = z.infer;
+
+export const PartToWholeChartSchema = z.object({
+ chartGroup: z.literal('partToWhole'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of data points showing parts of a whole'),
+ variantOptions: z.object({
+ pie: z.record(z.string(), z.any()).optional(),
+ donut: z.record(z.string(), z.any()).optional(),
+ treemap: z.record(z.string(), z.any()).optional(),
+ sunburst: z.record(z.string(), z.any()).optional(),
+ }).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['pie', 'donut', 'treemap', 'sunburst']).optional().describe('Default variant to display'),
+});
+
+export const CategoryComparisonChartSchema = z.object({
+ chartGroup: z.literal('categoryComparison'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of data points for category comparison'),
+ variantOptions: z.record(
+ z.enum(['bar', 'horizontalBar', 'pictorialBar']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['bar', 'horizontalBar', 'pictorialBar']).optional().describe('Default variant to display'),
+});
+
+export const TimeSeriesChartSchema = z.object({
+ chartGroup: z.literal('timeSeries'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of time-series data points'),
+ variantOptions: z.record(
+ z.enum(['line', 'area', 'step', 'smoothLine']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['line', 'area', 'step', 'smoothLine']).optional().describe('Default variant to display'),
+});
+
+export const DistributionChartSchema = z.object({
+ chartGroup: z.literal('distribution'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of data points for distribution analysis'),
+ variantOptions: z.record(
+ z.enum(['histogram', 'boxplot']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['histogram', 'boxplot']).optional().describe('Default variant to display'),
+});
+
+export const CorrelationChartSchema = z.object({
+ chartGroup: z.literal('correlation'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of data points for correlation analysis'),
+ variantOptions: z.record(
+ z.enum(['scatter', 'effectScatter', 'heatmap']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['scatter', 'effectScatter', 'heatmap']).optional().describe('Default variant to display'),
+});
+
+export const RankingChartSchema = z.object({
+ chartGroup: z.literal('ranking'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of data points to rank'),
+ variantOptions: z.record(
+ z.enum(['orderedBar', 'horizontalOrderedBar']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['orderedBar', 'horizontalOrderedBar']).optional().describe('Default variant to display'),
+});
+
+export const HierarchicalChartSchema = z.object({
+ chartGroup: z.literal('hierarchical'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of hierarchical data points'),
+ variantOptions: z.record(
+ z.enum(['treemap', 'sunburst', 'tree']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['treemap', 'sunburst', 'tree']).optional().describe('Default variant to display'),
+});
+
+export const FlowChartSchema = z.object({
+ chartGroup: z.literal('flow'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of flow/funnel data points'),
+ variantOptions: z.record(
+ z.enum(['sankey', 'funnel']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['sankey', 'funnel']).optional().describe('Default variant to display'),
+});
+
+export const GeospatialChartSchema = z.object({
+ chartGroup: z.literal('geospatial'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of geographic data points'),
+ variantOptions: z.record(
+ z.enum(['map']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['map']).optional().describe('Default variant to display'),
+});
+
+export const MultivariateComparisonChartSchema = z.object({
+ chartGroup: z.literal('multivariateComparison'),
+ title: z.string().optional().describe('Optional chart title'),
+ data: z.array(ChartDataPointSchema).describe('Array of data points for multivariate comparison'),
+ variantOptions: z.record(
+ z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']),
+ z.record(z.string(), z.unknown())
+ ).optional().describe('Optional variant-specific ECharts options'),
+ defaultVariant: z.enum(['groupedBar', 'stackedBar', 'radar', 'parallel']).optional().describe('Default variant to display'),
+});
+
+// Union of all chart schemas
+export const ChartConfigSchema = z.union([
+ PartToWholeChartSchema,
+ CategoryComparisonChartSchema,
+ TimeSeriesChartSchema,
+ DistributionChartSchema,
+ CorrelationChartSchema,
+ RankingChartSchema,
+ HierarchicalChartSchema,
+ FlowChartSchema,
+ GeospatialChartSchema,
+ MultivariateComparisonChartSchema,
+]);
+
+export type ChartConfig = z.infer;
+
+
+// ============================================================================
+// Chart Display Types
+// ============================================================================
+
+export type ChartGroup =
+ | 'partToWhole'
+ | 'categoryComparison'
+ | 'timeSeries'
+ | 'distribution'
+ | 'correlation'
+ | 'ranking'
+ | 'hierarchical'
+ | 'flow'
+ | 'geospatial'
+ | 'multivariateComparison';
+
+export type ChartVariant =
+ // partToWhole
+ | 'pie' | 'donut' | 'treemap' | 'sunburst'
+ // categoryComparison
+ | 'bar' | 'horizontalBar' | 'pictorialBar'
+ // timeSeries
+ | 'line' | 'area' | 'step' | 'smoothLine'
+ // distribution
+ | 'histogram' | 'boxplot'
+ // correlation
+ | 'scatter' | 'effectScatter' | 'heatmap'
+ // ranking
+ | 'orderedBar' | 'horizontalOrderedBar'
+ // hierarchical (overlaps with partToWhole)
+ | 'tree'
+ // flow
+ | 'sankey' | 'funnel'
+ // geospatial
+ | 'map'
+ // multivariateComparison
+ | 'groupedBar' | 'stackedBar' | 'radar' | 'parallel';
+
+export const ChartGroupVariants: Record = {
+ partToWhole: ['pie', 'donut', 'treemap', 'sunburst'],
+ categoryComparison: ['bar', 'horizontalBar', 'pictorialBar'],
+ timeSeries: ['line', 'area', 'step', 'smoothLine'],
+ distribution: ['histogram', 'boxplot'],
+ correlation: ['scatter', 'effectScatter', 'heatmap'],
+ ranking: ['orderedBar', 'horizontalOrderedBar'],
+ hierarchical: ['treemap', 'sunburst', 'tree'],
+ flow: ['sankey', 'funnel'],
+ geospatial: ['map'], // Removed scatter to avoid duplication with correlation group
+ multivariateComparison: ['groupedBar', 'stackedBar', 'radar', 'parallel'],
+};
+
+export type ChartDisplayInput = {
+ chart: ChartConfig;
+};
+
+export type ChartDisplayOutput = {
+ chartGroup: ChartGroup;
+ availableVariants: ChartVariant[];
+ currentVariant: ChartVariant;
+ option: EChartsOption;
+ data: ChartDataPoint[];
+ variantOptions: Partial>>;
+};
\ No newline at end of file
diff --git a/packages/cletus/src/operations/__tests__/chart-display.test.ts b/packages/cletus/src/operations/__tests__/chart-display.test.ts
new file mode 100644
index 0000000..b3fcd4e
--- /dev/null
+++ b/packages/cletus/src/operations/__tests__/chart-display.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect } from '@jest/globals';
+import { chart_display } from '../artist';
+
+describe('chart_display operation', () => {
+ it('should create a chart with partToWhole group', async () => {
+ const input = {
+ chartGroup: 'partToWhole' as const,
+ title: 'Test Chart',
+ data: [
+ { name: 'A', value: 10 },
+ { name: 'B', value: 20 },
+ { name: 'C', value: 30 },
+ ],
+ defaultVariant: 'pie' as const,
+ };
+
+ const output = await chart_display.do({ input } as any, {} as any);
+
+ expect(output.chartGroup).toBe('partToWhole');
+ expect(output.currentVariant).toBe('pie');
+ expect(output.availableVariants).toEqual(['pie', 'donut', 'treemap', 'sunburst']);
+ expect(output.data).toEqual(input.data);
+ expect(output.option).toBeDefined();
+ expect(output.option.series).toBeDefined();
+ expect(output.option.series.length).toBeGreaterThan(0);
+ });
+
+ it('should create a chart with categoryComparison group', async () => {
+ const input = {
+ chartGroup: 'categoryComparison' as const,
+ data: [
+ { name: 'X', value: 15 },
+ { name: 'Y', value: 25 },
+ ],
+ };
+
+ const output = await chart_display.do({ input } as any, {} as any);
+
+ expect(output.chartGroup).toBe('categoryComparison');
+ expect(output.currentVariant).toBe('bar');
+ expect(output.availableVariants).toEqual(['bar', 'horizontalBar', 'pictorialBar']);
+ });
+
+ it('should use default variant when not specified', async () => {
+ const input = {
+ chartGroup: 'timeSeries' as const,
+ data: [
+ { name: '2023', value: 100 },
+ { name: '2024', value: 150 },
+ ],
+ };
+
+ const output = await chart_display.do({ input } as any, {} as any);
+
+ expect(output.currentVariant).toBe('line'); // First variant in timeSeries group
+ });
+
+ it('should include title in option when provided', async () => {
+ const input = {
+ chartGroup: 'ranking' as const,
+ title: 'Top Items',
+ data: [
+ { name: 'Item 1', value: 50 },
+ { name: 'Item 2', value: 30 },
+ ],
+ };
+
+ const output = await chart_display.do({ input } as any, {} as any);
+
+ expect(output.option.title).toBeDefined();
+ expect(output.option.title.text).toBe('Top Items');
+ });
+
+ it('should merge variant options', async () => {
+ const input = {
+ chartGroup: 'partToWhole' as const,
+ data: [
+ { name: 'A', value: 10 },
+ ],
+ defaultVariant: 'pie' as const,
+ variantOptions: {
+ pie: {
+ series: [{
+ label: {
+ show: true,
+ position: 'outside',
+ },
+ }],
+ },
+ },
+ };
+
+ const output = await chart_display.do({ input } as any, {} as any);
+
+ expect(output.variantOptions.pie).toBeDefined();
+ });
+});
diff --git a/packages/cletus/src/operations/artist.tsx b/packages/cletus/src/operations/artist.tsx
index 5d55b1d..aea49c6 100644
--- a/packages/cletus/src/operations/artist.tsx
+++ b/packages/cletus/src/operations/artist.tsx
@@ -2,12 +2,15 @@ import { ImageGenerationResponse } from "@aeye/ai";
import fs from 'fs/promises';
import path from 'path';
import url from 'url';
-import { abbreviate, cosineSimilarity, linkFile, paginateText, pluralize } from "../common";
+import { abbreviate, cosineSimilarity, deepMerge, isObject, linkFile, paginateText, pluralize } from "../common";
import { canEmbed, embed } from "../embed";
import { getImagePath } from "../file-manager";
import { fileIsReadable, searchFiles } from "../helpers/files";
import { renderOperation } from "../helpers/render";
import { operationOf } from "./types";
+import { ChartDataPoint, ChartDisplayInput, ChartDisplayOutput, ChartGroupVariants, ChartVariant } from "../helpers/artist";
+import type { EChartsOption } from "echarts";
+
function resolveImage(cwd: string, imagePath: string): string {
const [_, _filename, filepath] = imagePath.match(/^\[([^\]]+)\]\(([^)]+)\)$/) || [];
@@ -480,6 +483,60 @@ export const image_attach = operationOf<
),
});
+export const chart_display = operationOf<
+ ChartDisplayInput,
+ ChartDisplayOutput
+>({
+ mode: 'local',
+ signature: 'chart_display(chart)',
+ status: (input) => `Displaying ${input.chart.chartGroup} chart`,
+ analyze: async ({ input }) => {
+ // Local operation, no analysis needed
+ return {
+ analysis: `This will display a ${input.chart.chartGroup} chart with ${input.chart.data?.length || 0} data points`,
+ doable: true,
+ };
+ },
+ do: async ({ input }) => {
+ const { chartGroup, data, title, variantOptions, defaultVariant } = input.chart;
+ const availableVariants = ChartGroupVariants[chartGroup];
+ const currentVariant = defaultVariant || availableVariants[0];
+ const variantOpts = (variantOptions || {}) as Partial>>;
+
+ // Build base option from input
+ const baseOption: EChartsOption = {
+ title: title ? { text: title, left: 'center' } : undefined,
+ tooltip: { trigger: 'item' },
+ legend: {},
+ series: [],
+ };
+
+ // Apply variant-specific options
+ const variantOption = variantOpts[currentVariant] || {};
+ const option = applyVariantToOption(baseOption, currentVariant, data, variantOption);
+
+ return {
+ chartGroup,
+ availableVariants,
+ currentVariant,
+ option,
+ data,
+ variantOptions: variantOpts,
+ };
+ },
+ render: (op, ai, showInput, showOutput) => renderOperation(
+ op,
+ `ChartDisplay(${op.input.chart.chartGroup}, ${op.input.chart.data?.length || 0} points)`,
+ (op) => {
+ if (op.output) {
+ return `Displaying ${op.output.chartGroup} chart as ${op.output.currentVariant}`;
+ }
+ return null;
+ },
+ showInput, showOutput
+ ),
+});
+
export const diagram_show = operationOf<
{ spec: string },
{ spec: string }
@@ -514,3 +571,188 @@ export const diagram_show = operationOf<
),
});
+/**
+ * Apply variant-specific transformations to the base option
+ *
+ * Note: This function is duplicated in browser/operations/artist.tsx as buildOptionForVariant because:
+ * 1. The browser code cannot import from Node.js-specific files
+ * 2. Extracting to shared.ts would require moving all chart logic there
+ * 3. The logic needs to be in sync for server-side and client-side rendering
+ *
+ * If making changes here, ensure the same changes are made in browser/operations/artist.tsx
+ */
+function applyVariantToOption(
+ baseOption: EChartsOption,
+ variant: ChartVariant,
+ data: ChartDataPoint[],
+ variantOption: Partial
+): EChartsOption {
+ const option = { ...baseOption };
+
+ // Apply variant-specific series configuration
+ switch (variant) {
+ case 'pie':
+ option.series = [{
+ type: 'pie',
+ radius: '50%',
+ data,
+ emphasis: {
+ itemStyle: {
+ shadowBlur: 10,
+ shadowOffsetX: 0,
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
+ },
+ },
+ }];
+ break;
+
+ case 'donut':
+ option.series = [{
+ type: 'pie',
+ radius: ['40%', '70%'],
+ data,
+ emphasis: {
+ itemStyle: {
+ shadowBlur: 10,
+ shadowOffsetX: 0,
+ shadowColor: 'rgba(0, 0, 0, 0.5)',
+ },
+ },
+ }];
+ break;
+
+ case 'treemap':
+ option.series = [{
+ type: 'treemap',
+ data,
+ }];
+ break;
+
+ case 'sunburst':
+ option.series = [{
+ type: 'sunburst',
+ data,
+ radius: [0, '90%'],
+ }];
+ break;
+
+ case 'bar':
+ option.xAxis = { type: 'category', data: data.map((d: any) => d.name) };
+ option.yAxis = { type: 'value' };
+ option.series = [{
+ type: 'bar',
+ data: data.map((d: any) => d.value),
+ }];
+ break;
+
+ case 'horizontalBar':
+ option.yAxis = { type: 'category', data: data.map((d: any) => d.name) };
+ option.xAxis = { type: 'value' };
+ option.series = [{
+ type: 'bar',
+ data: data.map((d: any) => d.value),
+ }];
+ break;
+
+ case 'pictorialBar':
+ option.xAxis = { type: 'category', data: data.map((d: any) => d.name) };
+ option.yAxis = { type: 'value' };
+ option.series = [{
+ type: 'pictorialBar',
+ data: data.map((d: any) => d.value),
+ symbol: 'rect',
+ }];
+ break;
+
+ case 'line':
+ option.xAxis = { type: 'category', data: data.map((d: any) => d.name) };
+ option.yAxis = { type: 'value' };
+ option.series = [{
+ type: 'line',
+ data: data.map((d: any) => d.value),
+ }];
+ break;
+
+ case 'area':
+ option.xAxis = { type: 'category', data: data.map((d: any) => d.name) };
+ option.yAxis = { type: 'value' };
+ option.series = [{
+ type: 'line',
+ data: data.map((d: any) => d.value),
+ areaStyle: {},
+ }];
+ break;
+
+ case 'step':
+ option.xAxis = { type: 'category', data: data.map((d: any) => d.name) };
+ option.yAxis = { type: 'value' };
+ option.series = [{
+ type: 'line',
+ data: data.map((d: any) => d.value),
+ step: 'start',
+ }];
+ break;
+
+ case 'smoothLine':
+ option.xAxis = { type: 'category', data: data.map((d: any) => d.name) };
+ option.yAxis = { type: 'value' };
+ option.series = [{
+ type: 'line',
+ data: data.map((d: any) => d.value),
+ smooth: true,
+ }];
+ break;
+
+ case 'histogram':
+ case 'boxplot':
+ case 'scatter':
+ case 'effectScatter':
+ case 'heatmap':
+ case 'tree':
+ case 'sankey':
+ case 'funnel':
+ case 'map':
+ case 'radar':
+ case 'parallel':
+ // Use the variant name directly as ECharts type (these are already correct)
+ option.series = [{
+ type: variant as any,
+ data,
+ }];
+ break;
+
+ case 'orderedBar':
+ case 'horizontalOrderedBar':
+ // Ordered bars are just bars with sorted data
+ const sortedData = [...data].sort((a: any, b: any) => b.value - a.value);
+ if (variant === 'horizontalOrderedBar') {
+ option.yAxis = { type: 'category', data: sortedData.map((d: any) => d.name) };
+ option.xAxis = { type: 'value' };
+ } else {
+ option.xAxis = { type: 'category', data: sortedData.map((d: any) => d.name) };
+ option.yAxis = { type: 'value' };
+ }
+ option.series = [{
+ type: 'bar',
+ data: sortedData.map((d: any) => d.value),
+ }];
+ break;
+
+ case 'groupedBar':
+ case 'stackedBar':
+ // Grouped and stacked bars need multiple series
+ // For now, treat as regular bar - requires more complex data structure
+ option.xAxis = { type: 'category', data: data.map((d: any) => d.name) };
+ option.yAxis = { type: 'value' };
+ option.series = [{
+ type: 'bar',
+ data: data.map((d: any) => d.value),
+ stack: variant === 'stackedBar' ? 'total' : undefined,
+ }];
+ break;
+ }
+
+ // Merge variant-specific options
+ return deepMerge(option, variantOption);
+}
+
diff --git a/packages/cletus/src/schemas.ts b/packages/cletus/src/schemas.ts
index 1db8d58..73c0123 100644
--- a/packages/cletus/src/schemas.ts
+++ b/packages/cletus/src/schemas.ts
@@ -280,6 +280,7 @@ export const OperationKindSchema = z.enum([
'image_describe',
'image_find',
'image_attach',
+ 'chart_display',
'diagram_show',
// clerk
'file_search',
diff --git a/packages/cletus/src/shared.ts b/packages/cletus/src/shared.ts
index a07f1f4..c4a9295 100644
--- a/packages/cletus/src/shared.ts
+++ b/packages/cletus/src/shared.ts
@@ -368,3 +368,43 @@ export function paginateText(
return text.slice(start, end);
}
}
+
+/**
+ * Deep merge two objects. This is useful for combining options objects.
+ * Arrays are replaced, not merged.
+ *
+ * @param target - target object
+ * @param source - source object to merge into target
+ * @returns merged object
+ */
+export function deepMerge(target: any, source: any): T {
+ if (!source) return target;
+
+ const output = { ...target };
+
+ if (isObject(target) && isObject(source)) {
+ Object.keys(source).forEach(key => {
+ if (isObject(source[key])) {
+ if (!(key in target)) {
+ output[key] = source[key];
+ } else {
+ output[key] = deepMerge(target[key], source[key]);
+ }
+ } else {
+ output[key] = source[key];
+ }
+ });
+ }
+
+ return output;
+}
+
+/**
+ * Check if a value is a plain object (not an array, not null, not a function).
+ *
+ * @param item - value to check
+ * @returns true if the value is a plain object
+ */
+export function isObject(item: any): boolean {
+ return item && typeof item === 'object' && !Array.isArray(item);
+}
diff --git a/packages/cletus/src/tools/ABOUT.md b/packages/cletus/src/tools/ABOUT.md
index c0af15f..0db0736 100644
--- a/packages/cletus/src/tools/ABOUT.md
+++ b/packages/cletus/src/tools/ABOUT.md
@@ -17,7 +17,7 @@ Cletus is a sophisticated AI assistant that lives in your terminal. It combines:
Cletus organizes its capabilities through specialized toolsets:
- **Architect** - Manages custom data type definitions
-- **Artist** - Handles image generation, editing, analysis, and search
+- **Artist** - Handles image generation, editing, analysis, and search (browser client has charts and diagrams)
- **Clerk** - Performs file operations, searching, and indexing
- **DBA** - Database-like operations on typed data with full CRUD support
- **Internet** - Web search, page scraping, and REST API calls
diff --git a/packages/cletus/src/tools/artist.ts b/packages/cletus/src/tools/artist.ts
index 4286140..5a429cb 100644
--- a/packages/cletus/src/tools/artist.ts
+++ b/packages/cletus/src/tools/artist.ts
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { globalToolProperties, type CletusAI } from '../ai';
import { getOperationInput } from '../operations/types';
+import { ChartConfigSchema } from '../helpers/artist';
/**
* Create artist tools for image operations
@@ -116,6 +117,44 @@ Example: Attach an image file:
call: async (input, _, ctx) => ctx.ops.handle({ type: 'image_attach', input }, ctx),
});
+ // Chart display schemas - moved outside function and exported above
+
+ const chartDisplay = ai.tool({
+ name: 'chart_display',
+ description: 'Display data as an interactive chart in the browser UI',
+ instructions: `Use this to visualize data as a chart. This is browser-only and will display an interactive chart with variant switching capabilities.
+
+Chart groups and their available variants:
+- partToWhole: pie, donut, treemap, sunburst (for showing parts of a whole)
+- categoryComparison: bar, horizontalBar, pictorialBar (for comparing categories)
+- timeSeries: line, area, step, smoothLine (for data over time)
+- distribution: histogram, boxplot (for data distribution)
+- correlation: scatter, effectScatter, heatmap (for showing relationships)
+- ranking: orderedBar, horizontalOrderedBar (for ranked data)
+- hierarchical: treemap, sunburst, tree (for hierarchical data)
+- flow: sankey, funnel (for flow/process data)
+- geospatial: map (for geographic data)
+- multivariateComparison: groupedBar, stackedBar, radar, parallel (for comparing multiple variables)
+
+Data format: Provide an array of objects with 'name' and 'value' properties, e.g.:
+[{ "name": "Apple", "value": 28 }, { "name": "Samsung", "value": 22 }]
+
+The chart will be displayed in the browser with controls to switch between different variants of the same chart group.
+
+Example: Display market share as a pie chart:
+{ "chartGroup": "partToWhole", "title": "Market Share", "data": [{"name": "Apple", "value": 28}, {"name": "Samsung", "value": 22}], "defaultVariant": "pie" }
+
+{{modeInstructions}}`,
+ schema: z.object({
+ chart: ChartConfigSchema,
+ ...globalToolProperties,
+ }),
+ strict: false,
+ metadata: { onlyClient: 'browser' },
+ input: getOperationInput('chart_display'),
+ call: async (input, _, ctx) => ctx.ops.handle({ type: 'chart_display', input }, ctx),
+ });
+
const diagramShow = ai.tool({
name: 'diagram_show',
description: 'Display a Mermaid diagram in the chat (browser only)',
@@ -141,6 +180,7 @@ Example: Show a flowchart:
imageDescribe,
imageFind,
imageAttach,
+ chartDisplay,
diagramShow,
] as [
typeof imageGenerate,
@@ -149,6 +189,7 @@ Example: Show a flowchart:
typeof imageDescribe,
typeof imageFind,
typeof imageAttach,
+ typeof chartDisplay,
typeof diagramShow,
];
}