🚀 RELEASE: Analytics Layer

This commit is contained in:
kumarabhirup 2026-02-11 18:35:35 -08:00
parent 19259b1e15
commit 49d05a0b1e
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
26 changed files with 5014 additions and 170 deletions

View File

@ -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 },
);
}
}

View File

@ -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 },
);
}
}

View File

@ -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 });

View File

@ -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<string, unknown>[];
/** 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<string, unknown>[];
compact?: boolean;
ChartComponent: typeof BarChart | typeof LineChart | typeof AreaChart;
SeriesComponent: typeof Bar | typeof Line | typeof Area;
areaProps?: Record<string, unknown>;
}) {
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 (
<ResponsiveContainer width="100%" height={height}>
<ChartComponent data={data} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid {...gridStyle} />
<XAxis
dataKey={xKey}
tick={axisStyle}
tickFormatter={formatLabel}
axisLine={{ stroke: "#262626" }}
tickLine={false}
/>
<YAxis
tick={axisStyle}
tickFormatter={formatValue}
axisLine={false}
tickLine={false}
width={48}
/>
<Tooltip {...ttStyle} formatter={formatValue} labelFormatter={formatLabel} />
{yKeys.length > 1 && !compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
{yKeys.map((key, i) => {
const color = colors[i % colors.length];
const props: Record<string, unknown> = {
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 <SeriesComponent {...props} />;
})}
</ChartComponent>
</ResponsiveContainer>
);
}
function PieDonutChart({
config,
data,
compact,
}: {
config: PanelConfig;
data: Record<string, unknown>[];
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 (
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={data}
dataKey={valueKey}
nameKey={nameKey}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={compact ? 70 : 110}
paddingAngle={2}
label={compact ? undefined : ((props: unknown) => {
const p = props as Record<string, unknown>;
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) => (
<Cell key={i} fill={colors[i % colors.length]} />
))}
</Pie>
<Tooltip {...ttStyle} formatter={formatValue} />
{!compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
</PieChart>
</ResponsiveContainer>
);
}
function RadarChartPanel({
config,
data,
compact,
}: {
config: PanelConfig;
data: Record<string, unknown>[];
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 (
<ResponsiveContainer width="100%" height={height}>
<RadarChart data={data} cx="50%" cy="50%" outerRadius={compact ? 60 : 100}>
<PolarGrid stroke="#262626" />
<PolarAngleAxis dataKey={nameKey} tick={{ fontSize: 11, fill: "#888" }} />
<PolarRadiusAxis tick={{ fontSize: 10, fill: "#888" }} />
{valueKeys.map((key, i) => (
<Radar
key={key}
name={key}
dataKey={key}
stroke={colors[i % colors.length]}
fill={colors[i % colors.length]}
fillOpacity={0.2}
/>
))}
<Tooltip {...ttStyle} />
{!compact && valueKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
</RadarChart>
</ResponsiveContainer>
);
}
function ScatterChartPanel({
config,
data,
compact,
}: {
config: PanelConfig;
data: Record<string, unknown>[];
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 (
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid {...gridStyle} />
<XAxis dataKey={xKey} tick={axisStyle} name={xKey} axisLine={{ stroke: "#262626" }} tickLine={false} />
<YAxis tick={axisStyle} tickFormatter={formatValue} axisLine={false} tickLine={false} width={48} />
<Tooltip {...ttStyle} />
{yKeys.map((key, i) => (
<Scatter
key={key}
name={key}
data={data}
fill={colors[i % colors.length]}
/>
))}
{!compact && yKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
</ScatterChart>
</ResponsiveContainer>
);
}
function FunnelChartPanel({
config,
data,
compact,
}: {
config: PanelConfig;
data: Record<string, unknown>[];
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 (
<ResponsiveContainer width="100%" height={height}>
<FunnelChart>
<Tooltip {...ttStyle} />
<Funnel
data={funnelData}
dataKey={valueKey}
nameKey={nameKey}
isAnimationActive
>
<LabelList
position="right"
fill="#888"
stroke="none"
fontSize={11}
dataKey={nameKey}
/>
</Funnel>
</FunnelChart>
</ResponsiveContainer>
);
}
// --- 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<string, unknown> = { ...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 (
<div
className="flex items-center justify-center rounded-xl"
style={{
height: compact ? 200 : 320,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
fontSize: 13,
}}
>
No data
</div>
);
}
switch (config.type) {
case "bar":
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
case "line":
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={LineChart} SeriesComponent={Line} />;
case "area":
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={AreaChart} SeriesComponent={Area} />;
case "pie":
return <PieDonutChart config={config} data={processedData} compact={compact} />;
case "donut":
return <PieDonutChart config={config} data={processedData} compact={compact} />;
case "radar":
case "radialBar":
return <RadarChartPanel config={config} data={processedData} compact={compact} />;
case "scatter":
return <ScatterChartPanel config={config} data={processedData} compact={compact} />;
case "funnel":
return <FunnelChartPanel config={config} data={processedData} compact={compact} />;
default:
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
}
}

View File

@ -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 (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
);
}
function XIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
);
}
// --- 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 (
<div className="flex items-center gap-1.5">
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
{filter.label}
</label>
<input
type="date"
value={current.from ?? ""}
onChange={(e) => 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",
}}
/>
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>to</span>
<input
type="date"
value={current.to ?? ""}
onChange={(e) => 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",
}}
/>
</div>
);
}
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 (
<div className="flex items-center gap-1.5">
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
{filter.label}
</label>
<select
value={current ?? ""}
onChange={(e) => onChange({ type: "select", value: e.target.value || undefined })}
className="px-2 py-1 rounded-md text-[11px] outline-none cursor-pointer"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
minWidth: 100,
}}
>
<option value="">All</option>
{options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
);
}
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 (
<div className="flex items-center gap-1.5">
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
{filter.label}
</label>
<div className="flex flex-wrap gap-1">
{options.map((opt) => {
const selected = current.includes(opt);
return (
<button
key={opt}
type="button"
onClick={() => toggleOption(opt)}
className="px-2 py-0.5 rounded-full text-[10px] transition-colors cursor-pointer"
style={{
background: selected ? "rgba(232, 93, 58, 0.15)" : "var(--color-surface)",
border: `1px solid ${selected ? "var(--color-accent)" : "var(--color-border)"}`,
color: selected ? "var(--color-accent)" : "var(--color-text-muted)",
}}
>
{opt}
</button>
);
})}
</div>
</div>
);
}
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 (
<div className="flex items-center gap-1.5">
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
{filter.label}
</label>
<input
type="number"
placeholder="Min"
value={current.min ?? ""}
onChange={(e) => 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)",
}}
/>
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>to</span>
<input
type="number"
placeholder="Max"
value={current.max ?? ""}
onChange={(e) => 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)",
}}
/>
</div>
);
}
// --- Main FilterBar ---
export function FilterBar({ filters, value, onChange }: FilterBarProps) {
// Fetch options for select/multiSelect filters
const [optionsMap, setOptionsMap] = useState<Record<string, string[]>>({});
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<string, string[]> = {};
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<string, unknown>[] = 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 (
<div
className="flex items-center gap-4 px-4 py-2.5 border-b flex-wrap"
style={{ borderColor: "var(--color-border)" }}
>
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: "var(--color-text-muted)" }}>
<FilterIcon />
Filters
</span>
{filters.map((filter) => {
const fv = value[filter.id];
switch (filter.type) {
case "dateRange":
return (
<DateRangeFilter
key={filter.id}
filter={filter}
value={fv}
onChange={(v) => handleFilterChange(filter.id, v)}
/>
);
case "select":
return (
<SelectFilter
key={filter.id}
filter={filter}
value={fv}
onChange={(v) => handleFilterChange(filter.id, v)}
options={optionsMap[filter.id] ?? []}
/>
);
case "multiSelect":
return (
<MultiSelectFilter
key={filter.id}
filter={filter}
value={fv}
onChange={(v) => handleFilterChange(filter.id, v)}
options={optionsMap[filter.id] ?? []}
/>
);
case "number":
return (
<NumberFilter
key={filter.id}
filter={filter}
value={fv}
onChange={(v) => handleFilterChange(filter.id, v)}
/>
);
default:
return null;
}
})}
{hasActiveFilters && (
<button
type="button"
onClick={clearFilters}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors cursor-pointer"
style={{
color: "var(--color-accent)",
background: "rgba(232, 93, 58, 0.1)",
}}
>
<XIcon />
Clear
</button>
)}
</div>
);
}

View File

@ -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 (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
}
function ExternalLinkIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" x2="21" y1="14" y2="3" />
</svg>
);
}
function PinIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="17" y2="22" />
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
</svg>
);
}
// --- Panel data state ---
type PanelData = {
rows: Record<string, unknown>[];
loading: boolean;
error?: string;
};
// --- Main ReportCard ---
export function ReportCard({ config }: ReportCardProps) {
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
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<string, PanelData> = {};
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 (
<div
className="rounded-xl overflow-hidden my-2"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
maxWidth: "100%",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-3 py-2 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 min-w-0">
<span style={{ color: "#22c55e" }}>
<ChartBarIcon />
</span>
<span
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{config.title}
</span>
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{
background: "rgba(34, 197, 94, 0.1)",
color: "#22c55e",
}}
>
{config.panels.length} chart{config.panels.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{!pinned && (
<button
type="button"
onClick={handlePin}
disabled={pinning}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors cursor-pointer disabled:opacity-40"
style={{
color: "var(--color-text-muted)",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
title="Save to workspace"
>
<PinIcon />
{pinning ? "Saving..." : "Pin"}
</button>
)}
{pinned && (
<span
className="text-[10px] px-2 py-1 rounded-md"
style={{ color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }}
>
Saved
</span>
)}
<a
href="/workspace"
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors"
style={{
color: "var(--color-accent)",
background: "rgba(232, 93, 58, 0.1)",
}}
>
<ExternalLinkIcon />
Open
</a>
</div>
</div>
{/* Description */}
{config.description && (
<div className="px-3 py-1.5">
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
{config.description}
</p>
</div>
)}
{/* Panels (compact mode) */}
<div className={`grid gap-2 p-2 ${visiblePanels.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
{visiblePanels.map((panel) => (
<CompactPanelCard
key={panel.id}
panel={panel}
data={panelData[panel.id]}
/>
))}
</div>
{/* More panels indicator */}
{config.panels.length > 2 && (
<div
className="px-3 py-1.5 text-center border-t"
style={{ borderColor: "var(--color-border)" }}
>
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>
+{config.panels.length - 2} more chart{config.panels.length - 2 !== 1 ? "s" : ""}
</span>
</div>
)}
</div>
);
}
// --- Compact panel card for inline rendering ---
function CompactPanelCard({
panel,
data,
}: {
panel: PanelConfig;
data?: PanelData;
}) {
return (
<div
className="rounded-lg overflow-hidden"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
>
<div className="px-2.5 py-1.5">
<h4
className="text-[11px] font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{panel.title}
</h4>
</div>
<div className="px-1 pb-1">
{data?.loading ? (
<div className="flex items-center justify-center" style={{ height: 200 }}>
<div
className="w-4 h-4 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
</div>
) : data?.error ? (
<div className="flex items-center justify-center" style={{ height: 200 }}>
<p className="text-[10px]" style={{ color: "#f87171" }}>
{data.error}
</p>
</div>
) : (
<ChartPanel config={panel} data={data?.rows ?? []} compact />
)}
</div>
</div>
);
}

View File

@ -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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
}
function RefreshIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
);
}
// --- Helpers ---
type PanelData = {
panelId: string;
rows: Record<string, unknown>[];
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<ReportConfig | null>(propConfig ?? null);
const [configLoading, setConfigLoading] = useState(!propConfig && !!reportPath);
const [configError, setConfigError] = useState<string | null>(null);
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
const [filterState, setFilterState] = useState<FilterState>({});
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<string, PanelData> = {};
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 (
<div className="flex items-center justify-center h-full gap-3">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Loading report...
</span>
</div>
);
}
// --- Error state ---
if (configError) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
<ChartBarIcon size={48} />
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Failed to load report
</p>
<p
className="text-xs px-3 py-2 rounded-lg max-w-md text-center"
style={{ background: "var(--color-surface)", color: "#f87171" }}
>
{configError}
</p>
</div>
);
}
if (!config) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
No report configuration found
</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Report header */}
<div
className="px-6 py-4 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2.5 mb-1">
<span style={{ color: "var(--color-accent)" }}>
<ChartBarIcon />
</span>
<h1
className="text-xl font-bold"
style={{ color: "var(--color-text)" }}
>
{config.title}
</h1>
</div>
{config.description && (
<p
className="text-sm ml-7"
style={{ color: "var(--color-text-muted)" }}
>
{config.description}
</p>
)}
</div>
<div className="flex items-center gap-2">
<span
className="text-[10px] px-2 py-1 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{config.panels.length} panel{config.panels.length !== 1 ? "s" : ""}
</span>
<span
className="text-[10px] px-2 py-1 rounded-full"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}}
>
{totalRows} rows
</span>
<button
type="button"
onClick={() => setRefreshKey((k) => k + 1)}
className="p-1.5 rounded-md transition-colors cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Refresh data"
>
<RefreshIcon />
</button>
</div>
</div>
</div>
{/* Filters */}
{config.filters && config.filters.length > 0 && (
<FilterBar
filters={config.filters}
value={filterState}
onChange={setFilterState}
/>
)}
{/* Panel grid */}
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-6 gap-5">
{config.panels.map((panel) => (
<PanelCard
key={panel.id}
panel={panel}
data={panelData[panel.id]}
/>
))}
</div>
</div>
</div>
);
}
// --- Individual panel card ---
function PanelCard({
panel,
data,
}: {
panel: PanelConfig;
data?: PanelData;
}) {
const colSpan = panelColSpan(panel.size);
return (
<div
className={`${colSpan} rounded-xl overflow-hidden`}
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
>
{/* Panel header */}
<div className="px-4 py-3 flex items-center justify-between">
<h3
className="text-sm font-medium"
style={{ color: "var(--color-text)" }}
>
{panel.title}
</h3>
{data && !data.loading && !data.error && (
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ color: "var(--color-text-muted)" }}
>
{data.rows.length} rows
</span>
)}
</div>
{/* Chart area */}
<div className="px-2 pb-3">
{data?.loading ? (
<div
className="flex items-center justify-center"
style={{ height: 320 }}
>
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
</div>
) : data?.error ? (
<div
className="flex flex-col items-center justify-center gap-2"
style={{ height: 320 }}
>
<p className="text-xs" style={{ color: "#f87171" }}>
Query error
</p>
<p
className="text-[10px] px-2 py-1 rounded max-w-xs text-center"
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171" }}
>
{data.error}
</p>
</div>
) : (
<ChartPanel config={panel} data={data?.rows ?? []} />
)}
</div>
</div>
);
}

View File

@ -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<string, FilterValue>;
export type FilterValue =
| { type: "dateRange"; from?: string; to?: string }
| { type: "select"; value?: string }
| { type: "multiSelect"; values?: string[] }
| { type: "number"; min?: number; max?: number };

View File

@ -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: () => <div className="h-48 rounded-xl animate-pulse" style={{ background: "var(--color-surface)" }} /> },
);
/* ─── 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<string, unknown> | undefined {
if (val && typeof val === "object" && !Array.isArray(val))
return val as Record<string, unknown>;
{return val as Record<string, unknown>;}
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 (
<div
key={index}
className="whitespace-pre-wrap text-[15px] leading-relaxed"
>
{segment.text}
</div>
);
}
{segments.map((segment, index) => {
if (segment.type === "text") {
return (
<ChainOfThought key={index} parts={segment.parts} />
<div
key={index}
className="whitespace-pre-wrap text-[15px] leading-relaxed"
>
{segment.text}
</div>
);
})}
}
if (segment.type === "report-artifact") {
return (
<ReportCard
key={index}
config={segment.config}
/>
);
}
return (
<ChainOfThought key={index} parts={segment.parts} />
);
})}
</div>
{isUser && (

View File

@ -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 (
<div>
@ -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({
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
) : node.type === "report" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
@ -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 (
<p className="text-xs text-[var(--color-text-muted)] px-3 py-1">
No reports yet. Ask the agent to create one.
</p>
);
}
return (
<div className="space-y-0.5">
{reports.map((report) => (
<a
key={report.path}
href={`/workspace?path=${encodeURIComponent(report.path)}`}
className="flex items-center gap-2 mx-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-text-muted)" }}
>
<span className="flex-shrink-0" style={{ color: "#22c55e" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
</span>
<span className="truncate flex-1">
{report.name.replace(/\.report\.json$/, "")}
</span>
</a>
))}
</div>
);
}
// --- Collapsible Header ---
function SectionHeader({
@ -474,8 +528,7 @@ export function Sidebar({
{/* Header with New Chat button */}
<div className="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between">
<h1 className="text-base font-bold flex items-center gap-2">
<span className="text-xl">🦞</span>
<span>OpenClaw</span>
<span>OpenClaw Dench</span>
</h1>
<button
onClick={onNewSession}
@ -506,8 +559,23 @@ export function Sidebar({
</div>
) : (
<>
{/* Chats (web sessions) */}
<div>
{/* Workspace */}
{workspaceTree.length > 0 && (
<div>
<SectionHeader
title="Workspace"
count={workspaceTree.length}
isOpen={openSections.has("workspace")}
onToggle={() => toggleSection("workspace")}
/>
{openSections.has("workspace") && (
<WorkspaceSection tree={workspaceTree} />
)}
</div>
)}
{/* Chats (web sessions) */}
<div>
<SectionHeader
title="Chats"
count={webSessions.length}
@ -523,17 +591,16 @@ export function Sidebar({
)}
</div>
{/* Workspace */}
{/* Reports */}
{workspaceTree.length > 0 && (
<div>
<SectionHeader
title="Workspace"
count={workspaceTree.length}
isOpen={openSections.has("workspace")}
onToggle={() => toggleSection("workspace")}
title="Reports"
isOpen={openSections.has("reports")}
onToggle={() => toggleSection("reports")}
/>
{openSections.has("workspace") && (
<WorkspaceSection tree={workspaceTree} />
{openSections.has("reports") && (
<ReportsSection tree={workspaceTree} />
)}
</div>
)}

View File

@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
// Load markdown renderer client-only to avoid SSR issues with ESM-only packages
const MarkdownContent = dynamic(
@ -18,6 +19,21 @@ const MarkdownContent = dynamic(
},
);
// Lazy-load ReportCard (uses Recharts which is heavy)
const ReportCard = dynamic(
() =>
import("../charts/report-card").then((m) => ({ default: m.ReportCard })),
{
ssr: false,
loading: () => (
<div
className="h-48 rounded-xl animate-pulse my-4"
style={{ background: "var(--color-surface)" }}
/>
),
},
);
type DocumentViewProps = {
content: string;
title?: string;
@ -33,6 +49,9 @@ export function DocumentView({ content, title }: DocumentViewProps) {
const markdownBody =
displayTitle && h1Match ? body.replace(/^#\s+.+\n?/, "") : body;
// Check if the markdown contains embedded report-json blocks
const hasReports = hasReportBlocks(markdownBody);
return (
<div className="max-w-3xl mx-auto px-6 py-8">
{displayTitle && (
@ -44,9 +63,41 @@ export function DocumentView({ content, title }: DocumentViewProps) {
</h1>
)}
<div className="workspace-prose">
<MarkdownContent content={markdownBody} />
</div>
{hasReports ? (
<EmbeddedReportContent content={markdownBody} />
) : (
<div className="workspace-prose">
<MarkdownContent content={markdownBody} />
</div>
)}
</div>
);
}
/**
* Renders markdown content that contains embedded report-json blocks.
* Splits the content into alternating markdown and interactive chart sections.
*/
function EmbeddedReportContent({ content }: { content: string }) {
const segments = splitReportBlocks(content);
return (
<div className="space-y-4">
{segments.map((segment, index) => {
if (segment.type === "report-artifact") {
return (
<div key={index} className="my-6">
<ReportCard config={segment.config} />
</div>
);
}
// Text segment -- render as markdown
return (
<div key={index} className="workspace-prose">
<MarkdownContent content={segment.text} />
</div>
);
})}
</div>
);
}

View File

@ -5,7 +5,7 @@ import { useState, useCallback } from "react";
export 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[];
@ -67,6 +67,16 @@ function DatabaseIcon() {
);
}
function ReportIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
@ -100,6 +110,8 @@ function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
return <FolderIcon open={open} />;
case "database":
return <DatabaseIcon />;
case "report":
return <ReportIcon />;
default:
return <FileIcon />;
}
@ -141,7 +153,9 @@ function TreeNodeItem({
? "#60a5fa"
: node.type === "database"
? "#c084fc"
: "var(--color-text-muted)";
: node.type === "report"
? "#22c55e"
: "var(--color-text-muted)";
return (
<div>

View File

@ -11,6 +11,7 @@ import { FileViewer } from "../components/workspace/file-viewer";
import { DatabaseViewer } from "../components/workspace/database-viewer";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { EmptyState } from "../components/workspace/empty-state";
import { ReportViewer } from "../components/charts/report-viewer";
// --- Types ---
@ -58,6 +59,7 @@ type ContentState =
| { kind: "document"; data: FileData; title: string }
| { kind: "file"; data: FileData; filename: string }
| { kind: "database"; dbPath: string; filename: string }
| { kind: "report"; reportPath: string; filename: string }
| { kind: "directory"; node: TreeNode };
// --- Helpers ---
@ -165,6 +167,9 @@ export default function WorkspacePage() {
} else if (node.type === "database") {
// Database files are handled entirely by the DatabaseViewer component
setContent({ kind: "database", dbPath: node.path, filename: node.name });
} else if (node.type === "report") {
// Report files are handled entirely by the ReportViewer component
setContent({ kind: "report", reportPath: node.path, filename: node.name });
} else if (node.type === "file") {
const res = await fetch(
`/api/workspace/file?path=${encodeURIComponent(node.path)}`,
@ -379,6 +384,13 @@ function ContentRenderer({
/>
);
case "report":
return (
<ReportViewer
reportPath={content.reportPath}
/>
);
case "directory":
return (
<DirectoryListing
@ -456,7 +468,9 @@ function DirectoryListing({
? "rgba(96, 165, 250, 0.1)"
: child.type === "database"
? "rgba(192, 132, 252, 0.1)"
: "var(--color-surface-hover)",
: child.type === "report"
? "rgba(34, 197, 94, 0.1)"
: "var(--color-surface-hover)",
color:
child.type === "object"
? "var(--color-accent)"
@ -464,7 +478,9 @@ function DirectoryListing({
? "#60a5fa"
: child.type === "database"
? "#c084fc"
: "var(--color-text-muted)",
: child.type === "report"
? "#22c55e"
: "var(--color-text-muted)",
}}
>
<NodeTypeIcon type={child.type} />
@ -665,6 +681,14 @@ function NodeTypeIcon({ type }: { type: string }) {
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
case "report":
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
default:
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View File

@ -0,0 +1,190 @@
import { describe, it, expect } from "vitest";
import { splitReportBlocks, hasReportBlocks } from "./report-blocks";
// ─── hasReportBlocks ───
describe("hasReportBlocks", () => {
it("returns false for plain text", () => {
expect(hasReportBlocks("Hello world")).toBe(false);
});
it("returns false for regular code blocks", () => {
expect(hasReportBlocks("```json\n{}\n```")).toBe(false);
});
it("returns true when report-json block is present", () => {
expect(hasReportBlocks('```report-json\n{"version":1,"title":"Test","panels":[]}\n```')).toBe(true);
});
it("returns true for partial/streaming content with marker", () => {
expect(hasReportBlocks("Some text ```report-json")).toBe(true);
});
});
// ─── splitReportBlocks ───
describe("splitReportBlocks", () => {
const validReport = JSON.stringify({
version: 1,
title: "Test Report",
panels: [{ id: "p1", title: "Panel 1", type: "bar", sql: "SELECT 1", mapping: { xAxis: "a" } }],
});
it("returns text segment for plain text with no blocks", () => {
const result = splitReportBlocks("Hello world");
expect(result).toEqual([{ type: "text", text: "Hello world" }]);
});
it("returns empty array for whitespace-only text", () => {
const result = splitReportBlocks(" ");
expect(result).toEqual([]);
});
it("parses a single report block", () => {
const text = `\`\`\`report-json\n${validReport}\n\`\`\``;
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("report-artifact");
if (result[0].type === "report-artifact") {
expect(result[0].config.title).toBe("Test Report");
expect(result[0].config.panels).toHaveLength(1);
}
});
it("splits text before and after a report block", () => {
const text = `Before text\n\n\`\`\`report-json\n${validReport}\n\`\`\`\n\nAfter text`;
const result = splitReportBlocks(text);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ type: "text", text: "Before text\n\n" });
expect(result[1].type).toBe("report-artifact");
expect(result[2]).toEqual({ type: "text", text: "\n\nAfter text" });
});
it("handles multiple report blocks", () => {
const report2 = JSON.stringify({
version: 1,
title: "Second Report",
panels: [{ id: "p2", title: "Panel 2", type: "pie", sql: "SELECT 2", mapping: { nameKey: "a" } }],
});
const text = `First:\n\`\`\`report-json\n${validReport}\n\`\`\`\nSecond:\n\`\`\`report-json\n${report2}\n\`\`\`\nDone.`;
const result = splitReportBlocks(text);
expect(result).toHaveLength(5);
expect(result[0].type).toBe("text");
expect(result[1].type).toBe("report-artifact");
expect(result[2].type).toBe("text");
expect(result[3].type).toBe("report-artifact");
expect(result[4].type).toBe("text");
if (result[1].type === "report-artifact") {
expect(result[1].config.title).toBe("Test Report");
}
if (result[3].type === "report-artifact") {
expect(result[3].config.title).toBe("Second Report");
}
});
it("falls back to text for invalid JSON", () => {
const text = "```report-json\n{not valid json}\n```";
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("text");
expect(result[0]).toEqual({ type: "text", text: "```report-json\n{not valid json}\n```" });
});
it("falls back to text for valid JSON without panels array", () => {
const text = '```report-json\n{"version":1,"title":"Bad"}\n```';
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("text");
});
it("falls back to text for JSON with panels as non-array", () => {
const text = '```report-json\n{"version":1,"title":"Bad","panels":"not-array"}\n```';
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("text");
});
it("handles report block at the very beginning with no preceding text", () => {
const text = `\`\`\`report-json\n${validReport}\n\`\`\`\nSome analysis.`;
const result = splitReportBlocks(text);
expect(result).toHaveLength(2);
expect(result[0].type).toBe("report-artifact");
expect(result[1].type).toBe("text");
});
it("handles report block at the very end with no following text", () => {
const text = `Here is the data:\n\`\`\`report-json\n${validReport}\n\`\`\``;
const result = splitReportBlocks(text);
expect(result).toHaveLength(2);
expect(result[0].type).toBe("text");
expect(result[1].type).toBe("report-artifact");
});
it("handles report-json with extra whitespace after language tag", () => {
const text = `\`\`\`report-json \n${validReport}\n\`\`\``;
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("report-artifact");
});
it("handles empty panels array (valid config, zero charts)", () => {
const emptyPanels = JSON.stringify({ version: 1, title: "Empty", panels: [] });
const text = `\`\`\`report-json\n${emptyPanels}\n\`\`\``;
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("report-artifact");
if (result[0].type === "report-artifact") {
expect(result[0].config.panels).toEqual([]);
}
});
it("preserves report config fields (description, filters)", () => {
const fullReport = JSON.stringify({
version: 1,
title: "Full Report",
description: "A detailed report",
panels: [{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: {} }],
filters: [{ id: "f1", type: "dateRange", label: "Date", column: "created_at" }],
});
const text = `\`\`\`report-json\n${fullReport}\n\`\`\``;
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
if (result[0].type === "report-artifact") {
expect(result[0].config.description).toBe("A detailed report");
expect(result[0].config.filters).toHaveLength(1);
}
});
it("does not match regular json code blocks", () => {
const text = '```json\n{"panels":[]}\n```';
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("text");
expect((result[0] as { type: "text"; text: string }).text).toContain("```json");
});
it("does not match inline backticks", () => {
const text = 'Use `report-json` format for charts.';
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("text");
});
it("handles SQL with special characters in report config", () => {
const reportWithSpecialSql = JSON.stringify({
version: 1,
title: "Special SQL",
panels: [{
id: "p1",
title: "P1",
type: "bar",
sql: `SELECT "Stage", COUNT(*) as count FROM v_deal WHERE "Name" LIKE '%O''Brien%' GROUP BY "Stage"`,
mapping: { xAxis: "Stage", yAxis: ["count"] },
}],
});
const text = `\`\`\`report-json\n${reportWithSpecialSql}\n\`\`\``;
const result = splitReportBlocks(text);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("report-artifact");
});
});

View File

@ -0,0 +1,75 @@
/**
* Pure utility functions for parsing report-json blocks from chat text.
* Extracted from chat-message.tsx for testability.
*/
export type ReportConfig = {
version: number;
title: string;
description?: string;
panels: Array<{
id: string;
title: string;
type: string;
sql: string;
mapping: Record<string, unknown>;
size?: string;
}>;
filters?: Array<{
id: string;
type: string;
label: string;
column: string;
sql?: string;
}>;
};
export type ParsedSegment =
| { type: "text"; text: string }
| { type: "report-artifact"; config: ReportConfig };
/**
* Split text containing ```report-json ... ``` fenced blocks into
* alternating text and report-artifact segments.
*/
export function splitReportBlocks(text: string): ParsedSegment[] {
const reportFenceRegex = /```report-json\s*\n([\s\S]*?)```/g;
const segments: ParsedSegment[] = [];
let lastIndex = 0;
for (const match of text.matchAll(reportFenceRegex)) {
const before = text.slice(lastIndex, match.index);
if (before.trim()) {
segments.push({ type: "text", text: before });
}
try {
const config = JSON.parse(match[1]) as ReportConfig;
if (config.panels && Array.isArray(config.panels)) {
segments.push({ type: "report-artifact", config });
} else {
// Invalid report config -- render as plain text
segments.push({ type: "text", text: match[0] });
}
} catch {
// Invalid JSON -- render as plain text
segments.push({ type: "text", text: match[0] });
}
lastIndex = (match.index ?? 0) + match[0].length;
}
const remaining = text.slice(lastIndex);
if (remaining.trim()) {
segments.push({ type: "text", text: remaining });
}
return segments;
}
/**
* Check if text contains any report-json fenced blocks.
*/
export function hasReportBlocks(text: string): boolean {
return text.includes("```report-json");
}

View File

@ -0,0 +1,312 @@
import { describe, it, expect } from "vitest";
import {
escapeSqlString,
buildFilterClauses,
injectFilters,
checkSqlSafety,
type FilterEntry,
} from "./report-filters";
// ─── escapeSqlString ───
describe("escapeSqlString", () => {
it("returns unchanged string when no quotes", () => {
expect(escapeSqlString("hello world")).toBe("hello world");
});
it("escapes single quotes", () => {
expect(escapeSqlString("O'Brien")).toBe("O''Brien");
});
it("escapes multiple single quotes", () => {
expect(escapeSqlString("it's a 'test'")).toBe("it''s a ''test''");
});
it("handles empty string", () => {
expect(escapeSqlString("")).toBe("");
});
it("does not double-escape already escaped quotes", () => {
expect(escapeSqlString("don''t")).toBe("don''''t");
});
});
// ─── buildFilterClauses ───
describe("buildFilterClauses", () => {
it("returns empty array for undefined filters", () => {
expect(buildFilterClauses(undefined)).toEqual([]);
});
it("returns empty array for empty filters array", () => {
expect(buildFilterClauses([])).toEqual([]);
});
// --- dateRange ---
it("builds dateRange clause with from only", () => {
const filters: FilterEntry[] = [
{ id: "d", column: "created_at", value: { type: "dateRange", from: "2025-01-01" } },
];
const clauses = buildFilterClauses(filters);
expect(clauses).toEqual([`"created_at" >= '2025-01-01'`]);
});
it("builds dateRange clause with to only", () => {
const filters: FilterEntry[] = [
{ id: "d", column: "created_at", value: { type: "dateRange", to: "2025-12-31" } },
];
const clauses = buildFilterClauses(filters);
expect(clauses).toEqual([`"created_at" <= '2025-12-31'`]);
});
it("builds dateRange clause with both from and to", () => {
const filters: FilterEntry[] = [
{ id: "d", column: "created_at", value: { type: "dateRange", from: "2025-01-01", to: "2025-12-31" } },
];
const clauses = buildFilterClauses(filters);
expect(clauses).toHaveLength(2);
expect(clauses[0]).toBe(`"created_at" >= '2025-01-01'`);
expect(clauses[1]).toBe(`"created_at" <= '2025-12-31'`);
});
it("skips dateRange with no from or to", () => {
const filters: FilterEntry[] = [
{ id: "d", column: "created_at", value: { type: "dateRange" } },
];
expect(buildFilterClauses(filters)).toEqual([]);
});
// --- select ---
it("builds select clause", () => {
const filters: FilterEntry[] = [
{ id: "s", column: "Status", value: { type: "select", value: "Active" } },
];
expect(buildFilterClauses(filters)).toEqual([`"Status" = 'Active'`]);
});
it("skips select with no value", () => {
const filters: FilterEntry[] = [
{ id: "s", column: "Status", value: { type: "select" } },
];
expect(buildFilterClauses(filters)).toEqual([]);
});
it("escapes SQL injection in select value", () => {
const filters: FilterEntry[] = [
{ id: "s", column: "Status", value: { type: "select", value: "'; DROP TABLE users; --" } },
];
const clauses = buildFilterClauses(filters);
// The single quote in the injected value is doubled, preventing breakout
// from the SQL string literal. "DROP TABLE" remains as inert text inside quotes.
expect(clauses[0]).toBe(`"Status" = '''; DROP TABLE users; --'`);
// Verify the quote is doubled (key defense)
expect(clauses[0]).toContain("''");
});
it("escapes multiple injection attempts in multiSelect", () => {
const filters: FilterEntry[] = [
{ id: "m", column: "Stage", value: { type: "multiSelect", values: ["a'b", "c'd"] } },
];
const clauses = buildFilterClauses(filters);
expect(clauses[0]).toBe(`"Stage" IN ('a''b', 'c''d')`);
});
// --- multiSelect ---
it("builds multiSelect clause with one value", () => {
const filters: FilterEntry[] = [
{ id: "m", column: "Stage", value: { type: "multiSelect", values: ["New"] } },
];
expect(buildFilterClauses(filters)).toEqual([`"Stage" IN ('New')`]);
});
it("builds multiSelect clause with multiple values", () => {
const filters: FilterEntry[] = [
{ id: "m", column: "Stage", value: { type: "multiSelect", values: ["New", "Active", "Closed"] } },
];
expect(buildFilterClauses(filters)).toEqual([`"Stage" IN ('New', 'Active', 'Closed')`]);
});
it("skips multiSelect with empty values", () => {
const filters: FilterEntry[] = [
{ id: "m", column: "Stage", value: { type: "multiSelect", values: [] } },
];
expect(buildFilterClauses(filters)).toEqual([]);
});
it("skips multiSelect with undefined values", () => {
const filters: FilterEntry[] = [
{ id: "m", column: "Stage", value: { type: "multiSelect" } },
];
expect(buildFilterClauses(filters)).toEqual([]);
});
// --- number ---
it("builds number clause with min only", () => {
const filters: FilterEntry[] = [
{ id: "n", column: "Amount", value: { type: "number", min: 100 } },
];
expect(buildFilterClauses(filters)).toEqual([`CAST("Amount" AS NUMERIC) >= 100`]);
});
it("builds number clause with max only", () => {
const filters: FilterEntry[] = [
{ id: "n", column: "Amount", value: { type: "number", max: 1000 } },
];
expect(buildFilterClauses(filters)).toEqual([`CAST("Amount" AS NUMERIC) <= 1000`]);
});
it("builds number clause with both min and max", () => {
const filters: FilterEntry[] = [
{ id: "n", column: "Amount", value: { type: "number", min: 100, max: 1000 } },
];
const clauses = buildFilterClauses(filters);
expect(clauses).toHaveLength(2);
expect(clauses[0]).toBe(`CAST("Amount" AS NUMERIC) >= 100`);
expect(clauses[1]).toBe(`CAST("Amount" AS NUMERIC) <= 1000`);
});
it("handles min of 0 correctly", () => {
const filters: FilterEntry[] = [
{ id: "n", column: "Score", value: { type: "number", min: 0 } },
];
// min is 0, which is defined, so should produce a clause
expect(buildFilterClauses(filters)).toEqual([`CAST("Score" AS NUMERIC) >= 0`]);
});
it("skips number with no min or max", () => {
const filters: FilterEntry[] = [
{ id: "n", column: "Amount", value: { type: "number" } },
];
expect(buildFilterClauses(filters)).toEqual([]);
});
// --- column escaping ---
it("escapes double quotes in column names", () => {
const filters: FilterEntry[] = [
{ id: "s", column: 'Bad"Column', value: { type: "select", value: "x" } },
];
expect(buildFilterClauses(filters)).toEqual([`"Bad""Column" = 'x'`]);
});
// --- multiple filters combined ---
it("builds clauses for multiple different filter types", () => {
const filters: FilterEntry[] = [
{ id: "d", column: "created_at", value: { type: "dateRange", from: "2025-01-01" } },
{ id: "s", column: "Status", value: { type: "select", value: "Active" } },
{ id: "n", column: "Amount", value: { type: "number", min: 50 } },
];
const clauses = buildFilterClauses(filters);
expect(clauses).toHaveLength(3);
expect(clauses[0]).toContain("created_at");
expect(clauses[1]).toContain("Status");
expect(clauses[2]).toContain("Amount");
});
});
// ─── injectFilters ───
describe("injectFilters", () => {
it("returns original SQL when no clauses", () => {
const sql = "SELECT * FROM v_deals";
expect(injectFilters(sql, [])).toBe(sql);
});
it("wraps SQL as CTE with single filter", () => {
const sql = "SELECT * FROM v_deals";
const clauses = [`"Status" = 'Active'`];
const result = injectFilters(sql, clauses);
expect(result).toBe(
`WITH __report_data AS (SELECT * FROM v_deals) SELECT * FROM __report_data WHERE "Status" = 'Active'`,
);
});
it("wraps SQL with multiple filters joined by AND", () => {
const sql = "SELECT * FROM v_deals";
const clauses = [`"Status" = 'Active'`, `"Amount" >= 100`];
const result = injectFilters(sql, clauses);
expect(result).toContain("AND");
expect(result).toContain(`"Status" = 'Active'`);
expect(result).toContain(`"Amount" >= 100`);
});
it("strips trailing semicolon from original SQL", () => {
const sql = "SELECT * FROM v_deals;";
const clauses = [`"Status" = 'Active'`];
const result = injectFilters(sql, clauses);
expect(result).toContain("AS (SELECT * FROM v_deals)");
expect(result).not.toContain("v_deals;)");
});
it("handles complex SQL with GROUP BY", () => {
const sql = `SELECT "Stage", COUNT(*) as cnt FROM v_deals GROUP BY "Stage"`;
const clauses = [`"Status" = 'Active'`];
const result = injectFilters(sql, clauses);
expect(result).toContain("__report_data");
expect(result).toContain("GROUP BY");
// The CTE wraps the entire query, so the GROUP BY is inside the CTE
expect(result).toMatch(/^WITH __report_data AS/);
});
it("preserves SQL with existing WITH clause", () => {
const sql = `WITH base AS (SELECT * FROM v_deals) SELECT * FROM base`;
const clauses = [`"x" = '1'`];
const result = injectFilters(sql, clauses);
// The original CTE gets nested inside __report_data
expect(result).toContain("__report_data");
expect(result).toContain("WITH base AS");
});
});
// ─── checkSqlSafety ───
describe("checkSqlSafety", () => {
it("allows SELECT queries", () => {
expect(checkSqlSafety("SELECT * FROM v_deals")).toBeNull();
});
it("allows SELECT with leading whitespace", () => {
expect(checkSqlSafety(" SELECT * FROM v_deals")).toBeNull();
});
it("allows WITH (CTE) queries", () => {
expect(checkSqlSafety("WITH base AS (SELECT 1) SELECT * FROM base")).toBeNull();
});
it("rejects DROP statements", () => {
expect(checkSqlSafety("DROP TABLE users")).not.toBeNull();
});
it("rejects DELETE statements", () => {
expect(checkSqlSafety("DELETE FROM users")).not.toBeNull();
});
it("rejects INSERT statements", () => {
expect(checkSqlSafety("INSERT INTO users VALUES (1)")).not.toBeNull();
});
it("rejects UPDATE statements", () => {
expect(checkSqlSafety("UPDATE users SET name = 'x'")).not.toBeNull();
});
it("rejects ALTER statements", () => {
expect(checkSqlSafety("ALTER TABLE users ADD COLUMN x INT")).not.toBeNull();
});
it("rejects CREATE statements", () => {
expect(checkSqlSafety("CREATE TABLE x (id INT)")).not.toBeNull();
});
it("rejects TRUNCATE statements", () => {
expect(checkSqlSafety("TRUNCATE TABLE users")).not.toBeNull();
});
it("is case-insensitive", () => {
expect(checkSqlSafety("drop table users")).not.toBeNull();
expect(checkSqlSafety("Drop Table Users")).not.toBeNull();
});
it("allows SELECT that contains mutation keywords in column names", () => {
// The SQL starts with SELECT, so it should be allowed
expect(checkSqlSafety('SELECT "delete_count", "update_time" FROM v_stats')).toBeNull();
});
});

View File

@ -0,0 +1,86 @@
/**
* Pure utility functions for report filter SQL injection.
* Extracted from the execute API route for testability.
*/
export type FilterValue =
| { type: "dateRange"; from?: string; to?: string }
| { type: "select"; value?: string }
| { type: "multiSelect"; values?: string[] }
| { type: "number"; min?: number; max?: number };
export type FilterEntry = {
id: string;
column: string;
value: FilterValue;
};
/** Escape single quotes in SQL string values. */
export function escapeSqlString(s: string): string {
return s.replace(/'/g, "''");
}
/**
* Build WHERE clause fragments from active filters.
* Returns an array of SQL condition strings (safe -- values are escaped).
*/
export function buildFilterClauses(filters?: FilterEntry[]): string[] {
if (!filters || filters.length === 0) {return [];}
const clauses: string[] = [];
for (const f of filters) {
const col = `"${f.column.replace(/"/g, '""')}"`;
const v = f.value;
if (v.type === "dateRange") {
if (v.from) {
clauses.push(`${col} >= '${escapeSqlString(v.from)}'`);
}
if (v.to) {
clauses.push(`${col} <= '${escapeSqlString(v.to)}'`);
}
} else if (v.type === "select" && v.value) {
clauses.push(`${col} = '${escapeSqlString(v.value)}'`);
} else if (v.type === "multiSelect" && v.values && v.values.length > 0) {
const vals = v.values.map((x) => `'${escapeSqlString(x)}'`).join(", ");
clauses.push(`${col} IN (${vals})`);
} else if (v.type === "number") {
if (v.min !== undefined) {
clauses.push(`CAST(${col} AS NUMERIC) >= ${Number(v.min)}`);
}
if (v.max !== undefined) {
clauses.push(`CAST(${col} AS NUMERIC) <= ${Number(v.max)}`);
}
}
}
return clauses;
}
/**
* Inject filter WHERE clauses into a SQL query.
* Strategy: wrap the original query as a CTE and filter on top.
*/
export function injectFilters(sql: string, filterClauses: string[]): string {
if (filterClauses.length === 0) {return sql;}
const whereClause = filterClauses.join(" AND ");
// Wrap original SQL as CTE to avoid parsing complexities
return `WITH __report_data AS (${sql.replace(/;$/, "")}) SELECT * FROM __report_data WHERE ${whereClause}`;
}
/**
* Check if SQL is read-only (no mutation statements).
* Returns an error message if unsafe, or null if safe.
*/
export function checkSqlSafety(sql: string): string | null {
const upper = sql.toUpperCase().trim();
const forbidden = ["DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "CREATE", "TRUNCATE"];
for (const keyword of forbidden) {
if (upper.startsWith(keyword)) {
return "Only SELECT queries are allowed in reports";
}
}
return null;
}

View File

@ -0,0 +1,341 @@
import { describe, it, expect } from "vitest";
import {
isReportFile,
classifyFileType,
reportTitleToSlug,
panelColSpan,
formatChartValue,
formatChartLabel,
validateReportConfig,
} from "./report-utils";
// ─── isReportFile ───
describe("isReportFile", () => {
it("returns true for .report.json files", () => {
expect(isReportFile("deals-pipeline.report.json")).toBe(true);
});
it("returns true for deeply nested report files", () => {
expect(isReportFile("analytics.report.json")).toBe(true);
});
it("returns false for regular JSON", () => {
expect(isReportFile("config.json")).toBe(false);
});
it("returns false for similarly named non-report files", () => {
expect(isReportFile("report.json")).toBe(false);
});
it("returns false for markdown", () => {
expect(isReportFile("report.md")).toBe(false);
});
it("returns false for empty string", () => {
expect(isReportFile("")).toBe(false);
});
});
// ─── classifyFileType ───
describe("classifyFileType", () => {
const mockIsDb = (n: string) => /\.(duckdb|sqlite|sqlite3|db)$/.test(n);
it("classifies .report.json as report", () => {
expect(classifyFileType("test.report.json", mockIsDb)).toBe("report");
});
it("classifies .duckdb as database", () => {
expect(classifyFileType("workspace.duckdb", mockIsDb)).toBe("database");
});
it("classifies .sqlite as database", () => {
expect(classifyFileType("data.sqlite", mockIsDb)).toBe("database");
});
it("classifies .md as document", () => {
expect(classifyFileType("readme.md", mockIsDb)).toBe("document");
});
it("classifies .mdx as document", () => {
expect(classifyFileType("page.mdx", mockIsDb)).toBe("document");
});
it("classifies .yaml as file", () => {
expect(classifyFileType("config.yaml", mockIsDb)).toBe("file");
});
it("classifies .txt as file", () => {
expect(classifyFileType("notes.txt", mockIsDb)).toBe("file");
});
it("report takes priority over other extensions", () => {
// .report.json should be "report", not "file"
expect(classifyFileType("x.report.json", mockIsDb)).toBe("report");
});
});
// ─── reportTitleToSlug ───
describe("reportTitleToSlug", () => {
it("converts simple title to slug", () => {
expect(reportTitleToSlug("Deals Pipeline")).toBe("deals-pipeline");
});
it("removes special characters", () => {
expect(reportTitleToSlug("Q1 2025 Revenue (Draft)")).toBe("q1-2025-revenue-draft");
});
it("trims leading/trailing hyphens", () => {
expect(reportTitleToSlug(" Hello World! ")).toBe("hello-world");
});
it("truncates to 40 characters", () => {
const long = "A".repeat(100);
expect(reportTitleToSlug(long).length).toBeLessThanOrEqual(40);
});
it("handles empty string", () => {
expect(reportTitleToSlug("")).toBe("");
});
it("handles unicode/emoji gracefully", () => {
const result = reportTitleToSlug("Sales Overview 📊");
expect(result).toBe("sales-overview");
expect(result).not.toContain("📊");
});
it("collapses multiple dashes", () => {
expect(reportTitleToSlug("a --- b")).toBe("a-b");
});
});
// ─── panelColSpan ───
describe("panelColSpan", () => {
it("returns col-span-6 for full", () => {
expect(panelColSpan("full")).toBe("col-span-6");
});
it("returns col-span-3 for half", () => {
expect(panelColSpan("half")).toBe("col-span-3");
});
it("returns col-span-2 for third", () => {
expect(panelColSpan("third")).toBe("col-span-2");
});
it("returns col-span-3 for undefined (default)", () => {
expect(panelColSpan(undefined)).toBe("col-span-3");
});
it("returns col-span-3 for unknown size", () => {
expect(panelColSpan("quarter")).toBe("col-span-3");
});
});
// ─── formatChartValue ───
describe("formatChartValue", () => {
it("returns empty string for null", () => {
expect(formatChartValue(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatChartValue(undefined)).toBe("");
});
it("formats millions", () => {
expect(formatChartValue(1_500_000)).toBe("1.5M");
});
it("formats thousands", () => {
expect(formatChartValue(1_500)).toBe("1.5K");
});
it("formats negative millions", () => {
expect(formatChartValue(-2_500_000)).toBe("-2.5M");
});
it("formats negative thousands", () => {
expect(formatChartValue(-2_500)).toBe("-2.5K");
});
it("formats integers below 1000 as-is", () => {
expect(formatChartValue(42)).toBe("42");
});
it("formats floats to 2 decimal places", () => {
expect(formatChartValue(3.14159)).toBe("3.14");
});
it("formats zero as integer", () => {
expect(formatChartValue(0)).toBe("0");
});
it("formats strings as-is", () => {
expect(formatChartValue("hello")).toBe("hello");
});
it("formats boolean as string", () => {
expect(formatChartValue(true)).toBe("true");
});
it("formats exactly 1000", () => {
expect(formatChartValue(1000)).toBe("1.0K");
});
it("formats exactly 1000000", () => {
expect(formatChartValue(1000000)).toBe("1.0M");
});
it("formats 999 as integer", () => {
expect(formatChartValue(999)).toBe("999");
});
});
// ─── formatChartLabel ───
describe("formatChartLabel", () => {
it("returns empty string for null", () => {
expect(formatChartLabel(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatChartLabel(undefined)).toBe("");
});
it("returns short strings unchanged", () => {
expect(formatChartLabel("Active")).toBe("Active");
});
it("truncates long strings", () => {
const long = "A".repeat(25);
expect(formatChartLabel(long)).toBe("A".repeat(18) + "...");
});
it("shortens ISO date strings", () => {
expect(formatChartLabel("2025-06-15T10:30:00Z")).toBe("2025-06-15");
});
it("shortens full datetime strings", () => {
expect(formatChartLabel("2025-06-15 10:30:00.000")).toBe("2025-06-15");
});
it("does not shorten non-date long strings", () => {
const notDate = "This is definitely not a date string at all";
expect(formatChartLabel(notDate)).toBe("This is definitely..." );
});
it("handles numbers by converting to string", () => {
expect(formatChartLabel(42)).toBe("42");
});
it("handles exactly 20-char string (no truncation)", () => {
expect(formatChartLabel("12345678901234567890")).toBe("12345678901234567890");
});
it("truncates 21-char string", () => {
expect(formatChartLabel("123456789012345678901")).toBe("123456789012345678...");
});
});
// ─── validateReportConfig ───
describe("validateReportConfig", () => {
const validConfig = {
version: 1,
title: "Test",
panels: [
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: { xAxis: "x" } },
],
};
it("returns null for valid config", () => {
expect(validateReportConfig(validConfig)).toBeNull();
});
it("returns null for valid config with filters", () => {
expect(validateReportConfig({
...validConfig,
filters: [{ id: "f1", type: "dateRange", label: "Date", column: "created_at" }],
})).toBeNull();
});
it("rejects null config", () => {
expect(validateReportConfig(null)).not.toBeNull();
});
it("rejects non-object config", () => {
expect(validateReportConfig("string")).not.toBeNull();
});
it("rejects missing title", () => {
expect(validateReportConfig({ panels: [] })).toContain("title");
});
it("rejects empty title", () => {
expect(validateReportConfig({ title: "", panels: [] })).toContain("title");
});
it("rejects missing panels", () => {
expect(validateReportConfig({ title: "Test" })).toContain("panels");
});
it("rejects non-array panels", () => {
expect(validateReportConfig({ title: "Test", panels: "not-array" })).toContain("panels");
});
it("accepts empty panels array", () => {
expect(validateReportConfig({ title: "Test", panels: [] })).toBeNull();
});
it("rejects panel without id", () => {
const config = { title: "Test", panels: [{ title: "P", type: "bar", sql: "SELECT 1", mapping: {} }] };
expect(validateReportConfig(config)).toContain("Panel 0");
expect(validateReportConfig(config)).toContain("id");
});
it("rejects panel without title", () => {
const config = { title: "Test", panels: [{ id: "p", type: "bar", sql: "SELECT 1", mapping: {} }] };
expect(validateReportConfig(config)).toContain("title");
});
it("rejects panel without type", () => {
const config = { title: "Test", panels: [{ id: "p", title: "P", sql: "SELECT 1", mapping: {} }] };
expect(validateReportConfig(config)).toContain("type");
});
it("rejects panel without sql", () => {
const config = { title: "Test", panels: [{ id: "p", title: "P", type: "bar", mapping: {} }] };
expect(validateReportConfig(config)).toContain("sql");
});
it("rejects panel without mapping", () => {
const config = { title: "Test", panels: [{ id: "p", title: "P", type: "bar", sql: "SELECT 1" }] };
expect(validateReportConfig(config)).toContain("mapping");
});
it("validates multiple panels", () => {
const config = {
title: "Test",
panels: [
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: {} },
{ id: "p2", title: "P2", type: "pie", sql: "SELECT 2", mapping: {} },
],
};
expect(validateReportConfig(config)).toBeNull();
});
it("reports correct panel index on validation error", () => {
const config = {
title: "Test",
panels: [
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: {} },
{ id: "p2", type: "pie", sql: "SELECT 2", mapping: {} }, // missing title
],
};
expect(validateReportConfig(config)).toContain("Panel 1");
});
});

View File

@ -0,0 +1,95 @@
/**
* Utility functions for report identification and helpers.
* Extracted for testability.
*/
/** Check if a filename is a report file (.report.json). */
export function isReportFile(filename: string): boolean {
return filename.endsWith(".report.json");
}
/**
* Classify a file's type for the tree display.
* Returns "report", "database", "document", or "file".
*/
export function classifyFileType(
name: string,
isDatabaseFile: (n: string) => boolean,
): "report" | "database" | "document" | "file" {
if (isReportFile(name)) {return "report";}
if (isDatabaseFile(name)) {return "database";}
const ext = name.split(".").pop()?.toLowerCase();
if (ext === "md" || ext === "mdx") {return "document";}
return "file";
}
/**
* Generate a slug from a report title for use as a filename.
*/
export function reportTitleToSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/**
* Determine the CSS grid column span class for a panel size.
*/
export 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";
}
}
/**
* Format a numeric value for chart display.
*/
export function formatChartValue(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);
}
/**
* Format a label for chart display (truncates long strings, shortens dates).
*/
export function formatChartLabel(val: unknown): string {
if (val === null || val === undefined) {return "";}
const str = String(val);
if (str.length > 16 && !isNaN(Date.parse(str))) {
return str.slice(0, 10);
}
if (str.length > 20) {return str.slice(0, 18) + "...";}
return str;
}
/**
* Validate a report config structure. Returns error message or null if valid.
*/
export function validateReportConfig(config: unknown): string | null {
if (!config || typeof config !== "object") {return "Config must be an object";}
const c = config as Record<string, unknown>;
if (typeof c.title !== "string" || !c.title) {return "Missing title";}
if (!Array.isArray(c.panels)) {return "panels must be an array";}
for (let i = 0; i < c.panels.length; i++) {
const p = c.panels[i] as Record<string, unknown>;
if (!p.id || typeof p.id !== "string") {return `Panel ${i}: missing id`;}
if (!p.title || typeof p.title !== "string") {return `Panel ${i}: missing title`;}
if (!p.type || typeof p.type !== "string") {return `Panel ${i}: missing type`;}
if (!p.sql || typeof p.sql !== "string") {return `Panel ${i}: missing sql`;}
if (!p.mapping || typeof p.mapping !== "object") {return `Panel ${i}: missing mapping`;}
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,9 @@
"scripts": {
"dev": "next dev --port 3100",
"build": "next build",
"start": "next start --port 3100"
"start": "next start --port 3100",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@ai-sdk/react": "^3.0.75",
@ -14,6 +16,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
@ -22,6 +25,7 @@
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^4.0.18"
}
}

File diff suppressed because one or more lines are too long

14
apps/web/vitest.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname),
},
},
test: {
include: ["lib/**/*.test.ts"],
testTimeout: 30_000,
},
});

View File

@ -613,6 +613,170 @@ You MUST complete ALL steps below after ANY schema mutation (create/update/delet
These steps ensure the filesystem always mirrors DuckDB. The sidebar depends on `.object.yaml` files — if they are missing, objects will not appear.
## Report Generation (Analytics / Charts)
Reports are JSON config files (`.report.json`) that the web app renders as live interactive dashboards using Recharts. The agent creates these files to give the user visual analytics over their CRM data.
### Report file format
Store reports as `.report.json` files in `dench/reports/` (create the directory if needed). The JSON schema:
```json
{
"version": 1,
"title": "Report Title",
"description": "Brief description of what this report shows",
"panels": [
{
"id": "unique-panel-id",
"title": "Panel Title",
"type": "bar",
"sql": "SELECT ... FROM v_{object} ...",
"mapping": { "xAxis": "column_name", "yAxis": ["value_column"] },
"size": "half"
}
],
"filters": [
{
"id": "filter-id",
"type": "dateRange",
"label": "Date Range",
"column": "created_at"
}
]
}
```
### Chart types
| Type | Best for | Required mapping |
| --------- | ---------------------------- | ------------------------------- |
| `bar` | Comparing categories | `xAxis`, `yAxis` |
| `line` | Trends over time | `xAxis`, `yAxis` |
| `area` | Volume trends | `xAxis`, `yAxis` |
| `pie` | Distribution/share | `nameKey`, `valueKey` |
| `donut` | Distribution (with center) | `nameKey`, `valueKey` |
| `radar` | Multi-dimensional comparison | `xAxis` (or `nameKey`), `yAxis` |
| `scatter` | Correlation | `xAxis`, `yAxis` |
| `funnel` | Pipeline/conversion | `nameKey`, `valueKey` |
### Panel sizes
- `"full"` — spans full width (6 columns)
- `"half"` — spans half width (3 columns) — **default**
- `"third"` — spans one third (2 columns)
### Filter types
- `dateRange` — date picker (from/to), filters on `column`
- `select` — single-select dropdown, needs `sql` to fetch options
- `multiSelect` — multi-select chips, needs `sql` to fetch options
- `number` — min/max numeric range
### SQL query rules for reports
- Always use the auto-generated `v_{object}` PIVOT views — never raw EAV queries
- SQL must be SELECT-only (no INSERT/UPDATE/DELETE)
- Cast numeric fields: `"Amount"::NUMERIC` or `CAST("Amount" AS NUMERIC)`
- Use `DATE_TRUNC('month', created_at)` for time-series grouping
- Always include `ORDER BY` for consistent chart rendering
- Use aggregate functions: `COUNT(*)`, `SUM(...)`, `AVG(...)`, `MIN(...)`, `MAX(...)`
### Example reports
**Pipeline Funnel:**
```json
{
"version": 1,
"title": "Deal Pipeline",
"description": "Deal count and value by stage",
"panels": [
{
"id": "deals-by-stage",
"title": "Deals by Stage",
"type": "funnel",
"sql": "SELECT \"Stage\", COUNT(*) as count FROM v_deal GROUP BY \"Stage\" ORDER BY count DESC",
"mapping": { "nameKey": "Stage", "valueKey": "count" },
"size": "half"
},
{
"id": "revenue-by-stage",
"title": "Revenue by Stage",
"type": "bar",
"sql": "SELECT \"Stage\", SUM(\"Amount\"::NUMERIC) as total FROM v_deal GROUP BY \"Stage\" ORDER BY total DESC",
"mapping": { "xAxis": "Stage", "yAxis": ["total"] },
"size": "half"
}
],
"filters": [
{ "id": "date", "type": "dateRange", "label": "Created", "column": "created_at" },
{
"id": "assignee",
"type": "select",
"label": "Assigned To",
"sql": "SELECT DISTINCT \"Assigned To\" as value FROM v_deal WHERE \"Assigned To\" IS NOT NULL",
"column": "Assigned To"
}
]
}
```
**Contact Growth:**
```json
{
"version": 1,
"title": "Contact Growth",
"description": "New contacts over time",
"panels": [
{
"id": "growth-trend",
"title": "Contacts Over Time",
"type": "area",
"sql": "SELECT DATE_TRUNC('month', created_at) as month, COUNT(*) as count FROM v_people GROUP BY month ORDER BY month",
"mapping": { "xAxis": "month", "yAxis": ["count"] },
"size": "full"
}
]
}
```
### Inline chat reports
When a user asks for analytics in chat (without explicitly asking to save a report), emit the report JSON inside a fenced code block with language `report-json`. The web UI will render interactive charts inline:
````
Here's your pipeline analysis:
```report-json
{"version":1,"title":"Deals by Stage","panels":[{"id":"p1","title":"Deal Count","type":"bar","sql":"SELECT \"Stage\", COUNT(*) as count FROM v_deal GROUP BY \"Stage\" ORDER BY count DESC","mapping":{"xAxis":"Stage","yAxis":["count"]},"size":"full"}]}
```
Most deals are currently in the Discovery stage.
````
The user can then "Pin" the inline report to save it as a `.report.json` file.
### Post-report checklist
After creating a `.report.json` file:
- [ ] Verify the report JSON is valid and all SQL queries work: test each panel's SQL individually
- [ ] Ensure `dench/reports/` directory exists
- [ ] Write the file: `dench/reports/{slug}.report.json`
- [ ] Tell the user they can view it in the workspace sidebar under "Reports"
### Choosing the right chart type
- **Comparing categories** (status breakdown, source distribution): `bar` or `pie`
- **Time series** (growth, trends, revenue over time): `line` or `area`
- **Pipeline/conversion** (deal stages, lead funnel): `funnel`
- **Distribution/proportion** (market share, segment split): `pie` or `donut`
- **Multi-metric comparison** (performance scores): `radar`
- **Correlation** (price vs. size, score vs. revenue): `scatter`
- When in doubt, `bar` is the safest default
## Critical Reminders
- Handle the ENTIRE CRM operation from analysis to SQL execution to filesystem projection to summary
@ -622,6 +786,8 @@ These steps ensure the filesystem always mirrors DuckDB. The sidebar depends on
- Use views (`v_{object}`) for all reads — never write raw PIVOT queries for search
- Never assume field names — verify with `SELECT * FROM fields WHERE object_id = ?`
- Extract ALL data from user messages — don't leave information unused
- **REPORTS vs DOCUMENTS**: When the user asks for "reports", "analytics", "charts", "graphs", "metrics", "insights", or "breakdown" — use `.report.json` format (see Report Generation section above), NOT markdown. Only use markdown `.md` for SOPs, guides, notes, and prose documents. Reports render as interactive Recharts dashboards; markdown does not.
- **INLINE CHART ARTIFACTS**: When answering analytics questions in chat, ALWAYS emit a `report-json` fenced code block so the UI renders interactive charts inline. Do NOT describe data in plain text when you can show it as a chart.
- **NOTES**: Always use type "richtext" for Notes fields
- **USER FIELDS**: Resolve member name to ID from `workspace_context.yaml` BEFORE inserting
- **ENUM FIELDS**: Use type "enum" with `enum_values` JSON array

View File

@ -11,6 +11,10 @@ vi.mock("chokidar", () => {
};
});
vi.mock("./bundled-dir.js", () => ({
resolveBundledSkillsDir: () => "/mock/package/root/skills",
}));
describe("ensureSkillsWatcher", () => {
it("ignores node_modules, dist, and .git by default", async () => {
const mod = await import("./refresh.js");
@ -28,4 +32,18 @@ describe("ensureSkillsWatcher", () => {
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true);
expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false);
});
it("includes bundled skills dir in watch paths", async () => {
watchMock.mockClear();
const mod = await import("./refresh.js");
// Force a fresh watcher by using a different workspace dir
mod.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace-bundled-test" });
expect(watchMock).toHaveBeenCalledTimes(1);
const watchedPaths = watchMock.mock.calls[0]?.[0] as string[];
// Should include workspace skills, managed skills, and bundled skills
expect(watchedPaths).toContain("/tmp/workspace-bundled-test/skills");
expect(watchedPaths).toContain("/mock/package/root/skills");
});
});

View File

@ -3,6 +3,7 @@ import path from "node:path";
import type { OpenClawConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { resolvePluginSkillDirs } from "./plugin-skills.js";
type SkillsChangeEvent = {
@ -52,6 +53,12 @@ function resolveWatchPaths(workspaceDir: string, config?: OpenClawConfig): strin
paths.push(path.join(workspaceDir, "skills"));
}
paths.push(path.join(CONFIG_DIR, "skills"));
// Also watch the bundled skills directory so changes to repo-level skills
// (e.g. skills/dench/SKILL.md) trigger snapshot refreshes without a restart.
const bundledDir = resolveBundledSkillsDir();
if (bundledDir) {
paths.push(bundledDir);
}
const extraDirsRaw = config?.skills?.load?.extraDirs ?? [];
const extraDirs = extraDirsRaw
.map((d) => (typeof d === "string" ? d.trim() : ""))