diff --git a/apps/web/app/api/workspace/file/route.ts b/apps/web/app/api/workspace/file/route.ts index 462c53d9dc0..f667bc92f62 100644 --- a/apps/web/app/api/workspace/file/route.ts +++ b/apps/web/app/api/workspace/file/route.ts @@ -1,4 +1,6 @@ -import { readWorkspaceFile } from "@/lib/workspace"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { readWorkspaceFile, safeResolvePath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -24,3 +26,45 @@ export async function GET(req: Request) { return Response.json(file); } + +/** + * POST /api/workspace/file + * Body: { path: string, content: string } + * + * Writes a file to the dench workspace. Creates parent directories as needed. + */ +export async function POST(req: Request) { + let body: { path?: string; content?: string }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { path: relPath, content } = body; + if (!relPath || typeof relPath !== "string" || typeof content !== "string") { + return Response.json( + { error: "Missing 'path' and 'content' fields" }, + { status: 400 }, + ); + } + + const absPath = safeResolvePath(relPath); + if (!absPath) { + return Response.json( + { error: "Invalid path or path traversal rejected" }, + { status: 400 }, + ); + } + + try { + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, content, "utf-8"); + return Response.json({ ok: true, path: relPath }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Write failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/workspace/reports/execute/route.ts b/apps/web/app/api/workspace/reports/execute/route.ts new file mode 100644 index 00000000000..fb6b8ed23a7 --- /dev/null +++ b/apps/web/app/api/workspace/reports/execute/route.ts @@ -0,0 +1,54 @@ +import { duckdbQuery } from "@/lib/workspace"; +import { buildFilterClauses, injectFilters, checkSqlSafety } from "@/lib/report-filters"; +import type { FilterEntry } from "@/lib/report-filters"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * POST /api/workspace/reports/execute + * + * Body: { sql: string, filters?: FilterEntry[] } + * + * Executes a report panel's SQL query with optional filter injection. + * Only SELECT-compatible queries are allowed. + */ +export async function POST(req: Request) { + let body: { + sql?: string; + filters?: FilterEntry[]; + }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { sql, filters } = body; + if (!sql || typeof sql !== "string") { + return Response.json( + { error: "Missing 'sql' field in request body" }, + { status: 400 }, + ); + } + + // Basic SQL safety: reject mutation statements + const safetyError = checkSqlSafety(sql); + if (safetyError) { + return Response.json({ error: safetyError }, { status: 403 }); + } + + // Build filter clauses and inject into SQL + const filterClauses = buildFilterClauses(filters); + const finalSql = injectFilters(sql, filterClauses); + + try { + const rows = duckdbQuery(finalSql); + return Response.json({ rows, sql: finalSql }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Query execution failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index 46112699542..1b25d7cb6ea 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -8,7 +8,7 @@ export const runtime = "nodejs"; export type TreeNode = { name: string; path: string; // relative to dench/ - type: "object" | "document" | "folder" | "file" | "database"; + type: "object" | "document" | "folder" | "file" | "database" | "report"; icon?: string; defaultView?: "table" | "kanban"; children?: TreeNode[]; @@ -117,13 +117,14 @@ function buildTree( } } else if (entry.isFile()) { const ext = entry.name.split(".").pop()?.toLowerCase(); + const isReport = entry.name.endsWith(".report.json"); const isDocument = ext === "md" || ext === "mdx"; const isDatabase = isDatabaseFile(entry.name); nodes.push({ name: entry.name, path: relPath, - type: isDatabase ? "database" : isDocument ? "document" : "file", + type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file", }); } } @@ -133,6 +134,7 @@ function buildTree( /** Classify a top-level file's type. */ function classifyFileType(name: string): TreeNode["type"] { + if (name.endsWith(".report.json")) {return "report";} if (isDatabaseFile(name)) {return "database";} const ext = name.split(".").pop()?.toLowerCase(); if (ext === "md" || ext === "mdx") {return "document";} @@ -149,6 +151,7 @@ export async function GET() { const dbObjects = loadDbObjects(); const knowledgeDir = join(root, "knowledge"); + const reportsDir = join(root, "reports"); const tree: TreeNode[] = []; // Build knowledge tree @@ -156,6 +159,19 @@ export async function GET() { tree.push(...buildTree(knowledgeDir, "knowledge", dbObjects)); } + // Build reports tree + if (existsSync(reportsDir)) { + const reportNodes = buildTree(reportsDir, "reports", dbObjects); + if (reportNodes.length > 0) { + tree.push({ + name: "reports", + path: "reports", + type: "folder", + children: reportNodes, + }); + } + } + // Add top-level files (WORKSPACE.md, workspace_context.yaml, workspace.duckdb, etc.) try { const topLevel = readdirSync(root, { withFileTypes: true }); diff --git a/apps/web/app/components/charts/chart-panel.tsx b/apps/web/app/components/charts/chart-panel.tsx new file mode 100644 index 00000000000..87acb447133 --- /dev/null +++ b/apps/web/app/components/charts/chart-panel.tsx @@ -0,0 +1,414 @@ +"use client"; + +import { useMemo } from "react"; +import { + BarChart, + Bar, + LineChart, + Line, + AreaChart, + Area, + PieChart, + Pie, + Cell, + RadarChart, + Radar, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + ScatterChart, + Scatter, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + FunnelChart, + Funnel, + LabelList, +} from "recharts"; +import type { PanelConfig } from "./types"; + +// --- Color palette derived from CSS variables + accessible defaults --- + +const CHART_PALETTE = [ + "#e85d3a", // accent + "#60a5fa", // blue + "#22c55e", // green + "#f59e0b", // amber + "#c084fc", // purple + "#fb923c", // orange + "#14b8a6", // teal + "#f43f5e", // rose + "#a78bfa", // violet + "#38bdf8", // sky +]; + +type ChartPanelProps = { + config: PanelConfig; + data: Record[]; + /** Compact mode for inline chat cards */ + compact?: boolean; +}; + +// --- Shared tooltip/axis styles --- + +const axisStyle = { + fontSize: 11, + fill: "#888", +}; + +const gridStyle = { + stroke: "#262626", + strokeDasharray: "3 3", +}; + +function tooltipStyle() { + return { + contentStyle: { + background: "#141414", + border: "1px solid #262626", + borderRadius: 8, + fontSize: 12, + color: "#ededed", + }, + itemStyle: { color: "#ededed" }, + labelStyle: { color: "#888", marginBottom: 4 }, + }; +} + +// --- Formatters --- + +function formatValue(val: unknown): string { + if (val === null || val === undefined) {return "";} + if (typeof val === "number") { + if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;} + if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;} + return Number.isInteger(val) ? String(val) : val.toFixed(2); + } + return String(val); +} + +function formatLabel(val: unknown): string { + if (val === null || val === undefined) {return "";} + const str = String(val); + // Truncate long date strings + if (str.length > 16 && !isNaN(Date.parse(str))) { + return str.slice(0, 10); + } + // Truncate long labels + if (str.length > 20) {return str.slice(0, 18) + "...";} + return str; +} + +// --- Chart renderers --- + +function CartesianChart({ + config, + data, + compact, + ChartComponent, + SeriesComponent, + areaProps, +}: { + config: PanelConfig; + data: Record[]; + compact?: boolean; + ChartComponent: typeof BarChart | typeof LineChart | typeof AreaChart; + SeriesComponent: typeof Bar | typeof Line | typeof Area; + areaProps?: Record; +}) { + const { mapping } = config; + const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x"; + const yKeys = mapping.yAxis ?? Object.keys(data[0] ?? {}).filter((k) => k !== xKey); + const colors = mapping.colors ?? CHART_PALETTE; + const height = compact ? 200 : 320; + const ttStyle = tooltipStyle(); + + return ( + + + + + + + {yKeys.length > 1 && !compact && } + {yKeys.map((key, i) => { + const color = colors[i % colors.length]; + const props: Record = { + key, + dataKey: key, + fill: color, + stroke: color, + name: key, + ...areaProps, + }; + if (SeriesComponent === Bar) { + props.radius = [4, 4, 0, 0]; + props.maxBarSize = 48; + } + if (SeriesComponent === Line) { + props.strokeWidth = 2; + props.dot = { r: 3, fill: color }; + props.activeDot = { r: 5 }; + } + if (SeriesComponent === Area) { + props.fillOpacity = 0.15; + props.strokeWidth = 2; + } + // @ts-expect-error - dynamic component props + return ; + })} + + + ); +} + +function PieDonutChart({ + config, + data, + compact, +}: { + config: PanelConfig; + data: Record[]; + compact?: boolean; +}) { + const { mapping, type } = config; + const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name"; + const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value"; + const colors = mapping.colors ?? CHART_PALETTE; + const height = compact ? 200 : 320; + const ttStyle = tooltipStyle(); + const innerRadius = type === "donut" ? "50%" : 0; + + return ( + + + { + const p = props as Record; + const name = p.name; + const percent = typeof p.percent === "number" ? p.percent : 0; + return `${formatLabel(name)} ${(percent * 100).toFixed(0)}%`; + }) as never} + labelLine={!compact} + style={{ fontSize: 11 }} + > + {data.map((_, i) => ( + + ))} + + + {!compact && } + + + ); +} + +function RadarChartPanel({ + config, + data, + compact, +}: { + config: PanelConfig; + data: Record[]; + compact?: boolean; +}) { + const { mapping } = config; + const nameKey = mapping.xAxis ?? mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name"; + const valueKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "value"]; + const colors = mapping.colors ?? CHART_PALETTE; + const height = compact ? 200 : 320; + const ttStyle = tooltipStyle(); + + return ( + + + + + + {valueKeys.map((key, i) => ( + + ))} + + {!compact && valueKeys.length > 1 && } + + + ); +} + +function ScatterChartPanel({ + config, + data, + compact, +}: { + config: PanelConfig; + data: Record[]; + compact?: boolean; +}) { + const { mapping } = config; + const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x"; + const yKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "y"]; + const colors = mapping.colors ?? CHART_PALETTE; + const height = compact ? 200 : 320; + const ttStyle = tooltipStyle(); + + return ( + + + + + + + {yKeys.map((key, i) => ( + + ))} + {!compact && yKeys.length > 1 && } + + + ); +} + +function FunnelChartPanel({ + config, + data, + compact, +}: { + config: PanelConfig; + data: Record[]; + compact?: boolean; +}) { + const { mapping } = config; + const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name"; + const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value"; + const colors = mapping.colors ?? CHART_PALETTE; + const height = compact ? 200 : 320; + const ttStyle = tooltipStyle(); + + // Funnel expects data with fill colors + const funnelData = data.map((row, i) => ({ + ...row, + fill: colors[i % colors.length], + })); + + return ( + + + + + + + + + ); +} + +// --- Main ChartPanel component --- + +export function ChartPanel({ config, data, compact }: ChartPanelProps) { + // Coerce numeric values for Recharts + const processedData = useMemo(() => { + if (!data || data.length === 0) {return [];} + const { mapping } = config; + const numericKeys = new Set([ + ...(mapping.yAxis ?? []), + ...(mapping.valueKey ? [mapping.valueKey] : []), + ]); + + return data.map((row) => { + const out: Record = { ...row }; + for (const key of numericKeys) { + if (key in out) { + const v = out[key]; + if (typeof v === "string" && v !== "" && !isNaN(Number(v))) { + out[key] = Number(v); + } + } + } + return out; + }); + }, [data, config]); + + if (processedData.length === 0) { + return ( +
+ No data +
+ ); + } + + switch (config.type) { + case "bar": + return ; + case "line": + return ; + case "area": + return ; + case "pie": + return ; + case "donut": + return ; + case "radar": + case "radialBar": + return ; + case "scatter": + return ; + case "funnel": + return ; + default: + return ; + } +} diff --git a/apps/web/app/components/charts/filter-bar.tsx b/apps/web/app/components/charts/filter-bar.tsx new file mode 100644 index 00000000000..c4961e9474a --- /dev/null +++ b/apps/web/app/components/charts/filter-bar.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import type { FilterConfig, FilterState, FilterValue } from "./types"; + +type FilterBarProps = { + filters: FilterConfig[]; + value: FilterState; + onChange: (state: FilterState) => void; +}; + +// --- Icons --- + +function FilterIcon() { + return ( + + + + ); +} + +function XIcon() { + return ( + + + + ); +} + +// --- Individual filter components --- + +function DateRangeFilter({ + filter, + value, + onChange, +}: { + filter: FilterConfig; + value: FilterValue | undefined; + onChange: (v: FilterValue) => void; +}) { + const current = value?.type === "dateRange" ? value : { type: "dateRange" as const }; + + return ( +
+ + onChange({ ...current, from: e.target.value || undefined })} + className="px-2 py-1 rounded-md text-[11px] outline-none" + style={{ + background: "var(--color-bg)", + border: "1px solid var(--color-border)", + color: "var(--color-text)", + colorScheme: "dark", + }} + /> + to + onChange({ ...current, to: e.target.value || undefined })} + className="px-2 py-1 rounded-md text-[11px] outline-none" + style={{ + background: "var(--color-bg)", + border: "1px solid var(--color-border)", + color: "var(--color-text)", + colorScheme: "dark", + }} + /> +
+ ); +} + +function SelectFilter({ + filter, + value, + onChange, + options, +}: { + filter: FilterConfig; + value: FilterValue | undefined; + onChange: (v: FilterValue) => void; + options: string[]; +}) { + const current = value?.type === "select" ? value.value : undefined; + + return ( +
+ + +
+ ); +} + +function MultiSelectFilter({ + filter, + value, + onChange, + options, +}: { + filter: FilterConfig; + value: FilterValue | undefined; + onChange: (v: FilterValue) => void; + options: string[]; +}) { + const current = value?.type === "multiSelect" ? (value.values ?? []) : []; + + const toggleOption = (opt: string) => { + const next = current.includes(opt) + ? current.filter((v) => v !== opt) + : [...current, opt]; + onChange({ type: "multiSelect", values: next.length > 0 ? next : undefined }); + }; + + return ( +
+ +
+ {options.map((opt) => { + const selected = current.includes(opt); + return ( + + ); + })} +
+
+ ); +} + +function NumberFilter({ + filter, + value, + onChange, +}: { + filter: FilterConfig; + value: FilterValue | undefined; + onChange: (v: FilterValue) => void; +}) { + const current = value?.type === "number" ? value : { type: "number" as const }; + + return ( +
+ + onChange({ ...current, min: e.target.value ? Number(e.target.value) : undefined })} + className="px-2 py-1 rounded-md text-[11px] outline-none w-20" + style={{ + background: "var(--color-bg)", + border: "1px solid var(--color-border)", + color: "var(--color-text)", + }} + /> + to + onChange({ ...current, max: e.target.value ? Number(e.target.value) : undefined })} + className="px-2 py-1 rounded-md text-[11px] outline-none w-20" + style={{ + background: "var(--color-bg)", + border: "1px solid var(--color-border)", + color: "var(--color-text)", + }} + /> +
+ ); +} + +// --- Main FilterBar --- + +export function FilterBar({ filters, value, onChange }: FilterBarProps) { + // Fetch options for select/multiSelect filters + const [optionsMap, setOptionsMap] = useState>({}); + + const fetchOptions = useCallback(async () => { + const toFetch = filters.filter( + (f) => (f.type === "select" || f.type === "multiSelect") && f.sql, + ); + if (toFetch.length === 0) {return;} + + const results: Record = {}; + await Promise.all( + toFetch.map(async (f) => { + try { + const res = await fetch("/api/workspace/reports/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: f.sql }), + }); + if (!res.ok) {return;} + const data = await res.json(); + const rows: Record[] = data.rows ?? []; + // Extract the first column's values as options + const opts = rows + .map((r) => { + const vals = Object.values(r); + return vals[0] != null ? String(vals[0]) : null; + }) + .filter((v): v is string => v !== null); + results[f.id] = opts; + } catch { + // skip failed option fetches + } + }), + ); + setOptionsMap(results); + }, [filters]); + + useEffect(() => { + fetchOptions(); + }, [fetchOptions]); + + const handleFilterChange = useCallback( + (filterId: string, v: FilterValue) => { + onChange({ ...value, [filterId]: v }); + }, + [value, onChange], + ); + + const hasActiveFilters = Object.values(value).some((v) => { + if (!v) {return false;} + if (v.type === "dateRange") {return v.from || v.to;} + if (v.type === "select") {return v.value;} + if (v.type === "multiSelect") {return v.values && v.values.length > 0;} + if (v.type === "number") {return v.min !== undefined || v.max !== undefined;} + return false; + }); + + const clearFilters = () => onChange({}); + + if (filters.length === 0) {return null;} + + return ( +
+ + + Filters + + + {filters.map((filter) => { + const fv = value[filter.id]; + switch (filter.type) { + case "dateRange": + return ( + handleFilterChange(filter.id, v)} + /> + ); + case "select": + return ( + handleFilterChange(filter.id, v)} + options={optionsMap[filter.id] ?? []} + /> + ); + case "multiSelect": + return ( + handleFilterChange(filter.id, v)} + options={optionsMap[filter.id] ?? []} + /> + ); + case "number": + return ( + handleFilterChange(filter.id, v)} + /> + ); + default: + return null; + } + })} + + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/apps/web/app/components/charts/report-card.tsx b/apps/web/app/components/charts/report-card.tsx new file mode 100644 index 00000000000..da92a209ba6 --- /dev/null +++ b/apps/web/app/components/charts/report-card.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { ChartPanel } from "./chart-panel"; +import type { ReportConfig, PanelConfig } from "./types"; + +type ReportCardProps = { + config: ReportConfig; +}; + +// --- Icons --- + +function ChartBarIcon() { + return ( + + + + + + ); +} + +function ExternalLinkIcon() { + return ( + + + + + + ); +} + +function PinIcon() { + return ( + + + + + ); +} + +// --- Panel data state --- + +type PanelData = { + rows: Record[]; + loading: boolean; + error?: string; +}; + +// --- Main ReportCard --- + +export function ReportCard({ config }: ReportCardProps) { + const [panelData, setPanelData] = useState>({}); + const [pinning, setPinning] = useState(false); + const [pinned, setPinned] = useState(false); + + // Show at most 2 panels inline + const visiblePanels = config.panels.slice(0, 2); + + // Execute panel SQL queries + const executePanels = useCallback(async () => { + const initial: Record = {}; + for (const panel of visiblePanels) { + initial[panel.id] = { rows: [], loading: true }; + } + setPanelData(initial); + + await Promise.all( + visiblePanels.map(async (panel) => { + try { + const res = await fetch("/api/workspace/reports/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sql: panel.sql }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setPanelData((prev) => ({ + ...prev, + [panel.id]: { rows: [], loading: false, error: data.error || `HTTP ${res.status}` }, + })); + return; + } + const data = await res.json(); + setPanelData((prev) => ({ + ...prev, + [panel.id]: { rows: data.rows ?? [], loading: false }, + })); + } catch (err) { + setPanelData((prev) => ({ + ...prev, + [panel.id]: { rows: [], loading: false, error: err instanceof Error ? err.message : "Failed" }, + })); + } + }), + ); + }, [visiblePanels]); + + useEffect(() => { + executePanels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Pin report to workspace filesystem + const handlePin = async () => { + setPinning(true); + try { + const slug = config.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + const filename = `${slug}.report.json`; + + await fetch("/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `reports/${filename}`, + content: JSON.stringify(config, null, 2), + }), + }); + setPinned(true); + } catch { + // silently fail + } finally { + setPinning(false); + } + }; + + return ( +
+ {/* Header */} +
+
+ + + + + {config.title} + + + {config.panels.length} chart{config.panels.length !== 1 ? "s" : ""} + +
+ +
+ {!pinned && ( + + )} + {pinned && ( + + Saved + + )} + + + Open + +
+
+ + {/* Description */} + {config.description && ( +
+

+ {config.description} +

+
+ )} + + {/* Panels (compact mode) */} +
1 ? "grid-cols-2" : "grid-cols-1"}`}> + {visiblePanels.map((panel) => ( + + ))} +
+ + {/* More panels indicator */} + {config.panels.length > 2 && ( +
+ + +{config.panels.length - 2} more chart{config.panels.length - 2 !== 1 ? "s" : ""} + +
+ )} +
+ ); +} + +// --- Compact panel card for inline rendering --- + +function CompactPanelCard({ + panel, + data, +}: { + panel: PanelConfig; + data?: PanelData; +}) { + return ( +
+
+

+ {panel.title} +

+
+
+ {data?.loading ? ( +
+
+
+ ) : data?.error ? ( +
+

+ {data.error} +

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/app/components/charts/report-viewer.tsx b/apps/web/app/components/charts/report-viewer.tsx new file mode 100644 index 00000000000..cd251b3cb14 --- /dev/null +++ b/apps/web/app/components/charts/report-viewer.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import { ChartPanel } from "./chart-panel"; +import { FilterBar } from "./filter-bar"; +import type { ReportConfig, FilterState, PanelConfig, FilterConfig } from "./types"; + +type ReportViewerProps = { + /** Report config object (inline or loaded) */ + config?: ReportConfig; + /** Path to load report config from filesystem */ + reportPath?: string; +}; + +// --- Icons --- + +function ChartBarIcon({ size = 20 }: { size?: number }) { + return ( + + + + + + ); +} + +function RefreshIcon() { + return ( + + + + + + + ); +} + +// --- Helpers --- + +type PanelData = { + panelId: string; + rows: Record[]; + loading: boolean; + error?: string; +}; + +/** Build filter entries for the API from active filter state + filter configs. */ +function buildFilterEntries( + filterState: FilterState, + filterConfigs: FilterConfig[], +): Array<{ id: string; column: string; value: FilterState[string] }> { + const entries: Array<{ id: string; column: string; value: FilterState[string] }> = []; + for (const fc of filterConfigs) { + const v = filterState[fc.id]; + if (!v) {continue;} + // Only include if the filter has an active value + const hasValue = + (v.type === "dateRange" && (v.from || v.to)) || + (v.type === "select" && v.value) || + (v.type === "multiSelect" && v.values && v.values.length > 0) || + (v.type === "number" && (v.min !== undefined || v.max !== undefined)); + if (hasValue) { + entries.push({ id: fc.id, column: fc.column, value: v }); + } + } + return entries; +} + +// --- Grid size helpers --- + +function panelColSpan(size?: string): string { + switch (size) { + case "full": + return "col-span-6"; + case "third": + return "col-span-2"; + case "half": + default: + return "col-span-3"; + } +} + +// --- Main ReportViewer --- + +export function ReportViewer({ config: propConfig, reportPath }: ReportViewerProps) { + const [config, setConfig] = useState(propConfig ?? null); + const [configLoading, setConfigLoading] = useState(!propConfig && !!reportPath); + const [configError, setConfigError] = useState(null); + const [panelData, setPanelData] = useState>({}); + const [filterState, setFilterState] = useState({}); + const [refreshKey, setRefreshKey] = useState(0); + + // Load report config from filesystem if path provided + useEffect(() => { + if (propConfig) { + setConfig(propConfig); + return; + } + if (!reportPath) {return;} + + let cancelled = false; + setConfigLoading(true); + setConfigError(null); + + fetch(`/api/workspace/file?path=${encodeURIComponent(reportPath)}`) + .then(async (res) => { + if (!res.ok) {throw new Error(`Failed to load report: HTTP ${res.status}`);} + const data = await res.json(); + if (cancelled) {return;} + try { + const parsed = JSON.parse(data.content) as ReportConfig; + setConfig(parsed); + } catch { + throw new Error("Invalid report JSON"); + } + }) + .catch((err) => { + if (!cancelled) { + setConfigError(err instanceof Error ? err.message : "Failed to load report"); + } + }) + .finally(() => { + if (!cancelled) {setConfigLoading(false);} + }); + + return () => { cancelled = true; }; + }, [propConfig, reportPath]); + + // Execute all panel SQL queries when config or filters change + const executeAllPanels = useCallback(async () => { + if (!config) {return;} + + const filterEntries = buildFilterEntries(filterState, config.filters ?? []); + + // Mark all panels as loading + const initialState: Record = {}; + for (const panel of config.panels) { + initialState[panel.id] = { panelId: panel.id, rows: [], loading: true }; + } + setPanelData(initialState); + + // Execute all panels in parallel + await Promise.all( + config.panels.map(async (panel) => { + try { + const res = await fetch("/api/workspace/reports/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sql: panel.sql, + filters: filterEntries.length > 0 ? filterEntries : undefined, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setPanelData((prev) => ({ + ...prev, + [panel.id]: { + panelId: panel.id, + rows: [], + loading: false, + error: data.error || `HTTP ${res.status}`, + }, + })); + return; + } + + const data = await res.json(); + setPanelData((prev) => ({ + ...prev, + [panel.id]: { + panelId: panel.id, + rows: data.rows ?? [], + loading: false, + }, + })); + } catch (err) { + setPanelData((prev) => ({ + ...prev, + [panel.id]: { + panelId: panel.id, + rows: [], + loading: false, + error: err instanceof Error ? err.message : "Query failed", + }, + })); + } + }), + ); + }, [config, filterState]); + + // Re-execute when config, filters, or refresh key changes + useEffect(() => { + executeAllPanels(); + }, [executeAllPanels, refreshKey]); + + const totalRows = useMemo(() => { + return Object.values(panelData).reduce((sum, pd) => sum + pd.rows.length, 0); + }, [panelData]); + + // --- Loading state --- + if (configLoading) { + return ( +
+
+ + Loading report... + +
+ ); + } + + // --- Error state --- + if (configError) { + return ( +
+ +

+ Failed to load report +

+

+ {configError} +

+
+ ); + } + + if (!config) { + return ( +
+

+ No report configuration found +

+
+ ); + } + + return ( +
+ {/* Report header */} +
+
+
+
+ + + +

+ {config.title} +

+
+ {config.description && ( +

+ {config.description} +

+ )} +
+ +
+ + {config.panels.length} panel{config.panels.length !== 1 ? "s" : ""} + + + {totalRows} rows + + +
+
+
+ + {/* Filters */} + {config.filters && config.filters.length > 0 && ( + + )} + + {/* Panel grid */} +
+
+ {config.panels.map((panel) => ( + + ))} +
+
+
+ ); +} + +// --- Individual panel card --- + +function PanelCard({ + panel, + data, +}: { + panel: PanelConfig; + data?: PanelData; +}) { + const colSpan = panelColSpan(panel.size); + + return ( +
+ {/* Panel header */} +
+

+ {panel.title} +

+ {data && !data.loading && !data.error && ( + + {data.rows.length} rows + + )} +
+ + {/* Chart area */} +
+ {data?.loading ? ( +
+
+
+ ) : data?.error ? ( +
+

+ Query error +

+

+ {data.error} +

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/app/components/charts/types.ts b/apps/web/app/components/charts/types.ts new file mode 100644 index 00000000000..150dfdc3d36 --- /dev/null +++ b/apps/web/app/components/charts/types.ts @@ -0,0 +1,64 @@ +/** Shared types for the report/analytics system. */ + +export type ChartType = + | "bar" + | "line" + | "area" + | "pie" + | "donut" + | "radar" + | "radialBar" + | "scatter" + | "funnel"; + +export type PanelSize = "full" | "half" | "third"; + +export type PanelMapping = { + /** Key for x-axis or category axis */ + xAxis?: string; + /** One or more keys for y-axis values (supports stacked/multi-series) */ + yAxis?: string[]; + /** Key used as label for pie/donut/funnel */ + nameKey?: string; + /** Key used as value for pie/donut/funnel */ + valueKey?: string; + /** Custom colors for series (hex). Falls back to palette. */ + colors?: string[]; +}; + +export type PanelConfig = { + id: string; + title: string; + type: ChartType; + sql: string; + mapping: PanelMapping; + size?: PanelSize; +}; + +export type FilterType = "dateRange" | "select" | "multiSelect" | "number"; + +export type FilterConfig = { + id: string; + type: FilterType; + label: string; + column: string; + /** SQL to fetch available options (for select/multiSelect) */ + sql?: string; +}; + +export type ReportConfig = { + version: number; + title: string; + description?: string; + panels: PanelConfig[]; + filters?: FilterConfig[]; +}; + +/** Active filter values keyed by filter ID */ +export type FilterState = Record; + +export type FilterValue = + | { type: "dateRange"; from?: string; to?: string } + | { type: "select"; value?: string } + | { type: "multiSelect"; values?: string[] } + | { type: "number"; min?: number; max?: number }; diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index bdacb5ef005..230801ed51c 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -1,18 +1,28 @@ "use client"; +import dynamic from "next/dynamic"; import type { UIMessage } from "ai"; import { ChainOfThought, type ChainPart } from "./chain-of-thought"; +import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks"; +import type { ReportConfig } from "./charts/types"; + +// Lazy-load ReportCard (uses Recharts which is heavy) +const ReportCard = dynamic( + () => import("./charts/report-card").then((m) => ({ default: m.ReportCard })), + { ssr: false, loading: () =>
}, +); /* ─── Part grouping ─── */ type MessageSegment = | { type: "text"; text: string } - | { type: "chain"; parts: ChainPart[] }; + | { type: "chain"; parts: ChainPart[] } + | { type: "report-artifact"; config: ReportConfig }; /** Map AI SDK tool state string to a simplified status */ function toolStatus(state: string): "running" | "done" | "error" { - if (state === "output-available") return "done"; - if (state === "error") return "error"; + if (state === "output-available") {return "done";} + if (state === "error") {return "error";} return "running"; } @@ -34,10 +44,13 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { for (const part of parts) { if (part.type === "text") { flush(); - segments.push({ - type: "text", - text: (part as { type: "text"; text: string }).text, - }); + const text = (part as { type: "text"; text: string }).text; + // Check for report-json fenced blocks in text + if (hasReportBlocks(text)) { + segments.push(...splitReportBlocks(text) as MessageSegment[]); + } else { + segments.push({ type: "text", text }); + } } else if (part.type === "reasoning") { const rp = part as { type: "reasoning"; @@ -99,10 +112,12 @@ function asRecord( val: unknown, ): Record | undefined { if (val && typeof val === "object" && !Array.isArray(val)) - return val as Record; + {return val as Record;} return undefined; } +// splitReportBlocks and hasReportBlocks imported from @/lib/report-blocks + /* ─── Chat message ─── */ export function ChatMessage({ message }: { message: UIMessage }) { @@ -126,21 +141,29 @@ export function ChatMessage({ message }: { message: UIMessage }) { : "bg-[var(--color-surface)] text-[var(--color-text)]" }`} > - {segments.map((segment, index) => { - if (segment.type === "text") { - return ( -
- {segment.text} -
- ); - } + {segments.map((segment, index) => { + if (segment.type === "text") { return ( - +
+ {segment.text} +
); - })} + } + if (segment.type === "report-artifact") { + return ( + + ); + } + return ( + + ); + })}
{isUser && ( diff --git a/apps/web/app/components/sidebar.tsx b/apps/web/app/components/sidebar.tsx index e0e2115db7b..8c975f28b09 100644 --- a/apps/web/app/components/sidebar.tsx +++ b/apps/web/app/components/sidebar.tsx @@ -27,13 +27,13 @@ type MemoryFile = { type TreeNode = { name: string; path: string; - type: "object" | "document" | "folder" | "file" | "database"; + type: "object" | "document" | "folder" | "file" | "database" | "report"; icon?: string; defaultView?: "table" | "kanban"; children?: TreeNode[]; }; -type SidebarSection = "chats" | "skills" | "memories" | "workspace"; +type SidebarSection = "chats" | "skills" | "memories" | "workspace" | "reports"; type SidebarProps = { onSessionSelect?: (sessionId: string) => void; @@ -233,7 +233,9 @@ function WorkspaceTreeNode({ ? "#60a5fa" : node.type === "database" ? "#c084fc" - : "var(--color-text-muted)"; + : node.type === "report" + ? "#22c55e" + : "var(--color-text-muted)"; return (
@@ -243,7 +245,7 @@ function WorkspaceTreeNode({ onClick={() => { if (isExpandable) {onToggle(node.path);} // Navigate to workspace page for actionable items - if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database") { + if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database" || node.type === "report") { window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`; } }} @@ -287,6 +289,12 @@ function WorkspaceTreeNode({ + ) : node.type === "report" ? ( + + + + + ) : ( @@ -381,6 +389,52 @@ function WorkspaceSection({ tree }: { tree: TreeNode[] }) { ); } +// --- Reports Section --- + +function ReportsSection({ tree }: { tree: TreeNode[] }) { + // Collect all report nodes from the tree (recursive) + const reports: TreeNode[] = []; + function collect(nodes: TreeNode[]) { + for (const n of nodes) { + if (n.type === "report") {reports.push(n);} + if (n.children) {collect(n.children);} + } + } + collect(tree); + + if (reports.length === 0) { + return ( +

+ No reports yet. Ask the agent to create one. +

+ ); + } + + return ( + + ); +} + // --- Collapsible Header --- function SectionHeader({ @@ -474,8 +528,7 @@ export function Sidebar({ {/* Header with New Chat button */}

- 🦞 - OpenClaw + OpenClaw Dench