Skip to content

Commit e761c7b

Browse files
authored
feat: add AI-powered chart generation tab (#134)
* feat: add AI-powered chart generation tab (#485) * feat: expose evaluations, reliability, and history data sources to AI chart * fix: keep API key in memory only, not in sessionStorage * style: move eye toggle inside API key input field * fix: invert eye icon on API key toggle * style: split provider config and prompt input into separate cards * style: rotate bar chart x-axis labels 32deg with dynamic bottom margin * style: recombine prompt input and title into single card * style: use gap-4 for card spacing to match other dashboard pages * fix: mask API key input from PostHog * fix: dynamically calculate left margin for rotated x-axis labels * feat: add tooltips to bar and scatter charts showing series name and value * security: sanitize all LLM output with DOMPurify before innerHTML * feat: validate LLM specs, multi-chart support, chart quality improvements - Validate all LLM output fields against enum whitelists before use - Sanitize error messages to prevent API key leaks - Support up to 2 charts for cross-model comparison queries - Smarter chart type selection guidance in system prompt - Stricter DOMPurify config (whitelist tags/attrs) - Dynamic bar chart height based on data count - Scatter chart: zoom scaleExtent, grabCursor, instructions - Bar chart: instructions overlay * fix: resolve lint and build errors after master merge * refactor: rename feature gate events from powerx to generic, gate ai-chart tab * fix: restore nullish coalescing semantics in ai-chart ISL/OSL filter * refactor: rewrite AI chart prompt with dynamic constants and rich domain context * feat: add line and radar chart type support with data resolution * feat: line/radar/scatter chart rendering with dots, tooltips, crosshairs, and inline legend * fix: add chart buttons, zoom reset, inline legend spacing, and full instructions * feat: add framework/disagg/sort filters, topN distinct GPU mode, and export with title/legend
1 parent 2f5c3e7 commit e761c7b

14 files changed

Lines changed: 1771 additions & 8 deletions

File tree

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"class-variance-authority": "^0.7.1",
4949
"clsx": "^2.1.1",
5050
"d3": "^7.9.0",
51+
"dompurify": "^3.3.3",
5152
"gray-matter": "^4.0.3",
5253
"iwanthue": "^2.0.0",
5354
"lodash-es": "^4.18.1",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Metadata } from 'next';
2+
3+
import AiChartDisplay from '@/components/ai-chart/AiChartDisplay';
4+
import { tabMetadata } from '@/lib/tab-meta';
5+
6+
export const metadata: Metadata = tabMetadata('ai-chart');
7+
8+
export default function AiChartPage() {
9+
return <AiChartDisplay />;
10+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
'use client';
2+
3+
import { useCallback, useState } from 'react';
4+
import { AlertCircle, Eye, EyeOff, Sparkles } from 'lucide-react';
5+
6+
import { track } from '@/lib/analytics';
7+
import { PROVIDER_OPTIONS, getProviderLabel } from '@/lib/ai-providers';
8+
import { useAiChart } from '@/hooks/api/use-ai-chart';
9+
import { Button } from '@/components/ui/button';
10+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
11+
import { Input } from '@/components/ui/input';
12+
import {
13+
Select,
14+
SelectContent,
15+
SelectItem,
16+
SelectTrigger,
17+
SelectValue,
18+
} from '@/components/ui/select';
19+
import { Skeleton } from '@/components/ui/skeleton';
20+
import { Textarea } from '@/components/ui/textarea';
21+
22+
import type { AiProvider } from './types';
23+
import { EXAMPLE_PROMPTS } from './example-prompts';
24+
import AiChartResult from './AiChartResult';
25+
26+
export default function AiChartDisplay() {
27+
const [provider, setProvider] = useState<AiProvider>('openai');
28+
const [apiKeys, setApiKeys] = useState<Record<AiProvider, string>>({
29+
openai: '',
30+
anthropic: '',
31+
xai: '',
32+
google: '',
33+
});
34+
const [prompt, setPrompt] = useState('');
35+
const [showKey, setShowKey] = useState(false);
36+
const { result, isLoading, error, generate, reset } = useAiChart();
37+
38+
const apiKey = apiKeys[provider];
39+
40+
const handleProviderChange = useCallback((value: string) => {
41+
const newProvider = value as AiProvider;
42+
setProvider(newProvider);
43+
track('ai_chart_provider_changed', { provider: newProvider });
44+
}, []);
45+
46+
const handleSubmit = useCallback(() => {
47+
if (!apiKey.trim() || !prompt.trim()) return;
48+
track('ai_chart_prompt_submitted', { provider, prompt_length: prompt.length });
49+
generate(prompt, provider, apiKey);
50+
}, [apiKey, prompt, provider, generate]);
51+
52+
const handleExampleClick = useCallback((example: string, index: number) => {
53+
setPrompt(example);
54+
track('ai_chart_example_clicked', { example_index: index });
55+
}, []);
56+
57+
const handleKeyDown = useCallback(
58+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
59+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
60+
e.preventDefault();
61+
handleSubmit();
62+
}
63+
},
64+
[handleSubmit],
65+
);
66+
67+
return (
68+
<div className="flex flex-col gap-4">
69+
{/* Title, description & API Key */}
70+
<Card>
71+
<CardHeader className="pb-4">
72+
<CardTitle className="flex items-center gap-2">
73+
<Sparkles className="size-5" />
74+
AI Chart Generation
75+
</CardTitle>
76+
<CardDescription>
77+
Describe the chart you want in natural language. Your API key is stored in your browser
78+
and only used by your selected provider. We never see it.
79+
</CardDescription>
80+
</CardHeader>
81+
<CardContent>
82+
<div className="flex flex-col gap-4 sm:flex-row">
83+
<div className="w-full sm:w-48">
84+
<Select value={provider} onValueChange={handleProviderChange}>
85+
<SelectTrigger>
86+
<SelectValue />
87+
</SelectTrigger>
88+
<SelectContent>
89+
{PROVIDER_OPTIONS.map((p) => (
90+
<SelectItem key={p} value={p}>
91+
{getProviderLabel(p)}
92+
</SelectItem>
93+
))}
94+
</SelectContent>
95+
</Select>
96+
</div>
97+
<div className="relative flex-1">
98+
<Input
99+
className="pr-9"
100+
type={showKey ? 'text' : 'password'}
101+
placeholder={`${getProviderLabel(provider)} API Key`}
102+
value={apiKey}
103+
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider]: e.target.value }))}
104+
data-ph-no-capture
105+
autoComplete="off"
106+
/>
107+
<button
108+
type="button"
109+
className="text-muted-foreground hover:text-foreground absolute right-2.5 top-1/2 -translate-y-1/2 transition-colors"
110+
onClick={() => setShowKey((s) => !s)}
111+
aria-label={showKey ? 'Hide API key' : 'Show API key'}
112+
>
113+
{showKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
114+
</button>
115+
</div>
116+
</div>
117+
<div className="mt-4 space-y-2">
118+
<Textarea
119+
placeholder="Describe the chart you want to see..."
120+
value={prompt}
121+
onChange={(e) => setPrompt(e.target.value)}
122+
onKeyDown={handleKeyDown}
123+
rows={3}
124+
className="resize-none"
125+
/>
126+
<div className="flex items-center justify-between">
127+
<span className="text-muted-foreground text-xs">
128+
{navigator.userAgent.includes('Mac') ? '⌘' : 'Ctrl'}+Enter to generate
129+
</span>
130+
<Button
131+
onClick={handleSubmit}
132+
disabled={isLoading || !apiKey.trim() || !prompt.trim()}
133+
>
134+
{isLoading ? 'Generating...' : 'Generate Chart'}
135+
</Button>
136+
</div>
137+
</div>
138+
</CardContent>
139+
</Card>
140+
141+
{/* Loading state */}
142+
{isLoading && (
143+
<Card>
144+
<CardContent className="space-y-4 pt-6">
145+
<Skeleton className="h-6 w-1/3" />
146+
<Skeleton className="h-100 w-full" />
147+
<Skeleton className="h-4 w-2/3" />
148+
</CardContent>
149+
</Card>
150+
)}
151+
152+
{/* Error state */}
153+
{error && (
154+
<Card className="border-destructive">
155+
<CardContent className="flex items-start gap-3 pt-6">
156+
<AlertCircle className="text-destructive mt-0.5 size-5 shrink-0" />
157+
<div>
158+
<p className="text-destructive text-sm font-medium">Error</p>
159+
<p className="text-muted-foreground text-sm">{error}</p>
160+
<Button variant="outline" size="sm" className="mt-2" onClick={reset}>
161+
Try Again
162+
</Button>
163+
</div>
164+
</CardContent>
165+
</Card>
166+
)}
167+
168+
{/* Result */}
169+
{result && <AiChartResult charts={result.charts} summary={result.summary} />}
170+
171+
{/* Example prompts (shown when no result) */}
172+
{!result && !isLoading && !error && (
173+
<div className="space-y-3">
174+
<h3 className="text-muted-foreground text-sm font-medium">Example prompts</h3>
175+
<div className="grid gap-2 sm:grid-cols-2">
176+
{EXAMPLE_PROMPTS.map((example, i) => (
177+
<button
178+
key={i}
179+
type="button"
180+
className="text-muted-foreground hover:bg-accent hover:text-foreground rounded-lg border p-3 text-left text-sm transition-colors"
181+
onClick={() => handleExampleClick(example, i)}
182+
>
183+
{example}
184+
</button>
185+
))}
186+
</div>
187+
</div>
188+
)}
189+
</div>
190+
);
191+
}

0 commit comments

Comments
 (0)