This commit is contained in:
qixinbo
2026-03-15 01:29:36 +08:00
parent 4985c1eed3
commit 76724b2313
12 changed files with 1345 additions and 82 deletions
+9 -4
View File
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { User, Loader2, Sparkles, Search, ArrowUp, ChevronDown, Table, Paperclip, Check, X, File as FileIcon } from "lucide-react";
import { api } from "@/lib/api";
import { useVisualizationStore } from "@/store/visualizationStore";
import { type ChartSpec, useVisualizationStore } from "@/store/visualizationStore";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
@@ -267,7 +267,12 @@ export function ChatInterface() {
} else {
// Fallback to existing NL2SQL or other skills (e.g. for "表格问答" or "深度问数")
const source = selectedDataSource.split('-')[0]; // postgres-main -> postgres
const response = await api.post<{sql?: string, result?: unknown, error?: string}>('/api/v1/agent/nl2sql', {
const response = await api.post<{
sql?: string,
result?: unknown,
error?: string,
chart?: { chart_spec: ChartSpec, reasoning: string, can_visualize: boolean }
}>('/api/v1/agent/nl2sql', {
query: messagePayload,
source: source,
session_id: activeSessionKey,
@@ -287,9 +292,9 @@ export function ChatInterface() {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `I've generated a SQL query and fetched ${rows.length} rows for you. Check the visualization panel.`
content: `I've generated a SQL query and fetched ${rows.length} rows for you. Check the visualization panel.${response.chart?.reasoning ? `\n\nVisualization reasoning: ${response.chart.reasoning}` : ''}`
}]);
setVisualization(rows, sql);
setVisualization(rows, sql, response.chart?.chart_spec);
}
}
} catch (error: any) {
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { VegaEmbed } from 'react-vega';
import type { ChartSpec } from '@/store/visualizationStore';
interface VegaChartProps {
data: any[];
spec: ChartSpec;
}
export const VegaChart: React.FC<VegaChartProps> = ({ data, spec }) => {
const vegaSpec: any = {
$schema: 'https://vega.github.io/schema/vega-lite/v5.json',
description: spec.description,
title: spec.title,
width: "container",
height: "container",
mark: { type: spec.chart_type, tooltip: true },
encoding: {
x: { field: spec.x_axis, type: 'nominal', axis: { labelAngle: -45 } },
y: { field: spec.y_axis, type: 'quantitative' },
},
data: { values: data }
};
if (spec.color) {
vegaSpec.encoding.color = { field: spec.color, type: 'nominal' };
}
return (
<div className="w-full h-full">
<VegaEmbed
spec={vegaSpec}
options={{ actions: false }}
style={{width: '100%', height: '100%'}}
/>
</div>
);
};
+16 -52
View File
@@ -3,25 +3,24 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line } from 'recharts';
import { Code, Table as TableIcon, BarChart as ChartIcon, LineChart as LineChartIcon, Download, LayoutDashboard, Loader2 } from "lucide-react";
import { Code, Table as TableIcon, BarChart as ChartIcon, Download, LayoutDashboard, Loader2 } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useDashboardStore } from "@/store/dashboardStore";
import { useVisualizationStore } from "@/store/visualizationStore";
import { VegaChart } from "./VegaChart";
export function VisualizationPanel() {
const [view, setView] = useState<'table' | 'chart'>('chart');
const [chartType, setChartType] = useState<'bar' | 'line'>('bar');
const { addChart } = useDashboardStore();
const { currentData, currentSQL, isLoading, error } = useVisualizationStore();
const { currentData, currentSQL, currentChartSpec, isLoading, error } = useVisualizationStore();
const handleAddToDashboard = () => {
if (!currentData || !currentSQL) return;
addChart({
id: Date.now().toString(),
title: 'Generated Analysis', // Could be dynamic based on query
type: chartType,
title: currentChartSpec?.title || 'Generated Analysis',
type: currentChartSpec?.chart_type as any || 'bar',
data: currentData,
sql: currentSQL,
});
@@ -67,9 +66,6 @@ export function VisualizationPanel() {
}
const columns = Object.keys(objectRows[0] as Record<string, unknown>);
const firstRow = objectRows[0] as Record<string, unknown>;
const stringColumn = columns.find(col => typeof firstRow[col] === 'string') || columns[0];
const numberColumns = columns.filter(col => typeof firstRow[col] === 'number');
return (
<div className="h-full flex flex-col bg-muted/10 overflow-hidden">
@@ -98,17 +94,6 @@ export function VisualizationPanel() {
</Button>
</div>
{view === 'chart' && (
<div className="flex gap-1 mr-2 border-r pr-2">
<Button variant={chartType === 'bar' ? 'secondary' : 'ghost'} size="icon" className="h-7 w-7" onClick={() => setChartType('bar')}>
<ChartIcon className="h-3.5 w-3.5" />
</Button>
<Button variant={chartType === 'line' ? 'secondary' : 'ghost'} size="icon" className="h-7 w-7" onClick={() => setChartType('line')}>
<LineChartIcon className="h-3.5 w-3.5" />
</Button>
</div>
)}
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard}>
<LayoutDashboard className="h-3.5 w-3.5 mr-1.5" />
Add to Dashboard
@@ -148,42 +133,21 @@ export function VisualizationPanel() {
<div className="flex-1 p-4 overflow-hidden min-h-0">
<Card className="h-full flex flex-col shadow-sm border-muted">
<CardHeader className="pb-2 shrink-0">
<CardTitle>Analysis Result</CardTitle>
<CardDescription>Generated from your query</CardDescription>
<CardTitle>{currentChartSpec?.title || 'Analysis Result'}</CardTitle>
<CardDescription>{currentChartSpec?.description || 'Generated from your query'}</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0 p-4">
{view === 'chart' ? (
<div className="h-full w-full">
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? (
<BarChart data={objectRows} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
<XAxis dataKey={stringColumn} tickLine={false} axisLine={false} tick={{ fontSize: 12, fill: '#6b7280' }} />
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12, fill: '#6b7280' }} />
<Tooltip
cursor={{ fill: 'rgba(0,0,0,0.05)' }}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Legend wrapperStyle={{ paddingTop: '20px' }} />
{numberColumns.map((col, idx) => (
<Bar key={col} dataKey={col} fill={`hsl(${idx * 60 + 200}, 70%, 50%)`} radius={[4, 4, 0, 0]} name={col} />
))}
</BarChart>
) : (
<LineChart data={objectRows} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
<XAxis dataKey={stringColumn} tickLine={false} axisLine={false} tick={{ fontSize: 12, fill: '#6b7280' }} />
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12, fill: '#6b7280' }} />
<Tooltip
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Legend wrapperStyle={{ paddingTop: '20px' }} />
{numberColumns.map((col, idx) => (
<Line key={col} type="monotone" dataKey={col} stroke={`hsl(${idx * 60 + 200}, 70%, 50%)`} strokeWidth={2} dot={{ r: 4 }} activeDot={{ r: 6 }} name={col} />
))}
</LineChart>
)}
</ResponsiveContainer>
{currentChartSpec ? (
<VegaChart data={objectRows} spec={currentChartSpec} />
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<ChartIcon className="h-12 w-12 mb-4 opacity-20" />
<p>No chart configuration available for this data.</p>
<Button variant="link" onClick={() => setView('table')}>View Table</Button>
</div>
)}
</div>
) : (
<ScrollArea className="h-full border rounded-md">
+14 -7
View File
@@ -1,13 +1,21 @@
import { create } from 'zustand';
export interface ChartSpec {
chart_type: string;
title: string;
x_axis: string;
y_axis: string;
color?: string;
description?: string;
}
export interface VisualizationState {
currentData: any[] | null;
currentSQL: string | null;
currentChartType: 'bar' | 'line';
currentChartSpec: ChartSpec | null;
isLoading: boolean;
error: string | null;
setVisualization: (data: any[], sql: string) => void;
setChartType: (type: 'bar' | 'line') => void;
setVisualization: (data: any[], sql: string, chartSpec?: ChartSpec | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearVisualization: () => void;
@@ -16,12 +24,11 @@ export interface VisualizationState {
export const useVisualizationStore = create<VisualizationState>((set) => ({
currentData: null,
currentSQL: null,
currentChartType: 'bar',
currentChartSpec: null,
isLoading: false,
error: null,
setVisualization: (data, sql) => set({ currentData: data, currentSQL: sql, error: null }),
setChartType: (type) => set({ currentChartType: type }),
setVisualization: (data, sql, chartSpec = null) => set({ currentData: data, currentSQL: sql, currentChartSpec: chartSpec, error: null }),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error, isLoading: false }),
clearVisualization: () => set({ currentData: null, currentSQL: null, error: null }),
clearVisualization: () => set({ currentData: null, currentSQL: null, currentChartSpec: null, error: null }),
}));