inconvo 0.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,36 @@
1
- # Placeholder
1
+ # `inconvo CLI`
2
+
3
+ Install Inconvo-flavored assistant-ui components into any project.
4
+
5
+ ## Usage
6
+
7
+ Add the assistant-ui tool components to the current working directory:
8
+
9
+ ```bash
10
+ npx inconvo@latest add assistant-ui-tool-components
11
+ ```
12
+
13
+ Options:
14
+
15
+ - `--cwd <path>` – run the installer against a different project directory
16
+ - `--path <relative>` – override the target folder (defaults to `components/assistant-ui/tools`)
17
+ - `--overwrite` – replace any files that already exist without prompting
18
+ - `--yes` – answer "yes" to interactive prompts
19
+
20
+ ## Development
21
+
22
+ ```bash
23
+ npm install
24
+ npm run build
25
+ ```
26
+
27
+ The component templates live under `templates/assistant-ui/tools`. Update those files whenever the upstream components change and cut a new CLI release.
28
+
29
+ ## Releasing
30
+
31
+ 1. In the npm package settings for `inconvo`, connect this repository as a *Trusted Publisher* (OIDC) so GitHub Actions can publish without storing an npm token.
32
+ 2. Follow conventional commit syntax (`feat:`, `fix:`, `chore:`…) when merging into `main`. Semantic Release reads those messages to determine the next version.
33
+ 3. Every push to `main` (or a manual *workflow_dispatch*) runs `.github/workflows/release.yml`, which installs dependencies, builds, and executes `npx semantic-release`. When it finds a release-worthy change it will:
34
+ - publish the package to npm with provenance,
35
+ - update the GitHub release notes.
36
+ 4. Verify with `npx inconvo@latest --help` after the workflow reports success.
@@ -0,0 +1,98 @@
1
+ import { Command } from "commander";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { logger } from "../lib/logger";
5
+ import { confirmPrompt } from "../lib/prompt";
6
+ import { componentPacks, getPack } from "../templates/packs";
7
+ export const addCommand = new Command()
8
+ .name("add")
9
+ .description("add a component pack to your project")
10
+ .argument("<pack>", "the component pack to install")
11
+ .option("-y, --yes", "answer yes to all prompts", false)
12
+ .option("-o, --overwrite", "overwrite existing files without prompting", false)
13
+ .option("-c, --cwd <cwd>", "working directory that will receive the files", process.cwd())
14
+ .option("-p, --path <path>", "relative path where the pack should be written")
15
+ .action(async (packName, opts) => {
16
+ const pack = getPack(packName);
17
+ if (!pack) {
18
+ const available = Object.keys(componentPacks).join(", ");
19
+ logger.error(`Unknown component pack "${packName}". Available packs: ${available}`);
20
+ process.exit(1);
21
+ }
22
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
23
+ const destinationRoot = path.resolve(cwd, opts.path ?? pack.targetDir);
24
+ logger.step(`Installing ${pack.name} into ${destinationRoot}`);
25
+ const templateFiles = await collectFiles(pack.templateDir);
26
+ if (templateFiles.length === 0) {
27
+ logger.warn("No files found in component pack.");
28
+ return;
29
+ }
30
+ const copyPlan = templateFiles.map((source) => {
31
+ const relative = path.relative(pack.templateDir, source);
32
+ const target = path.join(destinationRoot, relative);
33
+ return { source, target };
34
+ });
35
+ await fs.mkdir(destinationRoot, { recursive: true });
36
+ const conflicts = [];
37
+ for (const file of copyPlan) {
38
+ if (await fileExists(file.target)) {
39
+ conflicts.push(file.target);
40
+ }
41
+ }
42
+ let allowOverwrite = opts.overwrite ?? false;
43
+ if (conflicts.length && !allowOverwrite) {
44
+ if (opts.yes) {
45
+ allowOverwrite = true;
46
+ }
47
+ else {
48
+ const confirmed = await confirmPrompt(`Overwrite ${conflicts.length} existing file${conflicts.length > 1 ? "s" : ""}?`);
49
+ if (!confirmed) {
50
+ logger.warn("Aborted. No files were written.");
51
+ process.exit(0);
52
+ }
53
+ allowOverwrite = true;
54
+ }
55
+ }
56
+ const replaced = new Set();
57
+ for (const { source, target } of copyPlan) {
58
+ if (!allowOverwrite && (await fileExists(target))) {
59
+ logger.warn(`Skipped existing file ${target}`);
60
+ continue;
61
+ }
62
+ await fs.mkdir(path.dirname(target), { recursive: true });
63
+ await fs.copyFile(source, target);
64
+ if (conflicts.includes(target)) {
65
+ replaced.add(target);
66
+ }
67
+ }
68
+ if (replaced.size) {
69
+ logger.info(`Overwrote ${replaced.size} file(s).`);
70
+ }
71
+ logger.success(`Installed ${pack.name}!`);
72
+ });
73
+ async function collectFiles(dir) {
74
+ const entries = await fs.readdir(dir, { withFileTypes: true });
75
+ const files = [];
76
+ for (const entry of entries) {
77
+ const fullPath = path.join(dir, entry.name);
78
+ if (entry.isDirectory()) {
79
+ files.push(...(await collectFiles(fullPath)));
80
+ }
81
+ else if (entry.isFile()) {
82
+ files.push(fullPath);
83
+ }
84
+ }
85
+ return files;
86
+ }
87
+ async function fileExists(target) {
88
+ try {
89
+ await fs.stat(target);
90
+ return true;
91
+ }
92
+ catch (error) {
93
+ if (error && error.code === "ENOENT") {
94
+ return false;
95
+ }
96
+ throw error;
97
+ }
98
+ }
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { addCommand } from "./commands/add";
4
+ process.on("SIGINT", () => process.exit(0));
5
+ process.on("SIGTERM", () => process.exit(0));
6
+ const program = new Command()
7
+ .name("inconvo")
8
+ .description("Install Inconvo assistant-ui integrations into your project.");
9
+ program.addCommand(addCommand);
10
+ program.parse();
@@ -0,0 +1,18 @@
1
+ import chalk from "chalk";
2
+ export const logger = {
3
+ info(message) {
4
+ console.log(chalk.blue("info"), message);
5
+ },
6
+ step(message) {
7
+ console.log(chalk.cyan("•"), message);
8
+ },
9
+ success(message) {
10
+ console.log(chalk.green("success"), message);
11
+ },
12
+ warn(message) {
13
+ console.warn(chalk.yellow("warn"), message);
14
+ },
15
+ error(message) {
16
+ console.error(chalk.red("error"), message);
17
+ },
18
+ };
@@ -0,0 +1,7 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
4
+ const projectRoot = path.resolve(currentDir, "..", "..");
5
+ export function resolveFromRoot(...segments) {
6
+ return path.join(projectRoot, ...segments);
7
+ }
@@ -0,0 +1,12 @@
1
+ import readline from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ export async function confirmPrompt(message, defaultValue = false) {
4
+ const rl = readline.createInterface({ input, output });
5
+ const suffix = defaultValue ? " [Y/n] " : " [y/N] ";
6
+ const answer = (await rl.question(`${message}${suffix}`)).trim().toLowerCase();
7
+ rl.close();
8
+ if (!answer) {
9
+ return defaultValue;
10
+ }
11
+ return answer.startsWith("y");
12
+ }
@@ -0,0 +1,12 @@
1
+ import { resolveFromRoot } from "../lib/paths";
2
+ export const componentPacks = {
3
+ "assistant-ui-tool-components": {
4
+ name: "assistant-ui-tool-components",
5
+ description: "Inconvo-flavored assistant-ui tools and supporting components.",
6
+ templateDir: resolveFromRoot("templates", "assistant-ui", "tools"),
7
+ targetDir: "components/assistant-ui/tools",
8
+ },
9
+ };
10
+ export function getPack(name) {
11
+ return componentPacks[name];
12
+ }
package/package.json CHANGED
@@ -1,11 +1,33 @@
1
1
  {
2
2
  "name": "inconvo",
3
- "version": "0.0.1",
4
- "description": "",
5
- "main": "index.js",
3
+ "version": "1.1.0",
4
+ "description": "CLI for installing Inconvo assistant-ui tool components into any project.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "inconvo": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "templates",
13
+ "README.md"
14
+ ],
6
15
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
16
+ "build": "tsc -p tsconfig.json",
17
+ "dev": "tsx src/index.ts"
18
+ },
19
+ "dependencies": {
20
+ "chalk": "^5.3.0",
21
+ "commander": "^14.0.0"
8
22
  },
9
- "author": "",
10
- "license": "UNLICENSED"
23
+ "devDependencies": {
24
+ "@semantic-release/commit-analyzer": "^13.0.1",
25
+ "@semantic-release/github": "^12.0.2",
26
+ "@semantic-release/npm": "^13.1.2",
27
+ "@semantic-release/release-notes-generator": "^14.1.0",
28
+ "@types/node": "^22.8.1",
29
+ "semantic-release": "^25.0.2",
30
+ "tsx": "^4.20.6",
31
+ "typescript": "^5.6.3"
32
+ }
11
33
  }
@@ -0,0 +1,27 @@
1
+ const clamp = (value: number, min = 0, max = 1) =>
2
+ Math.min(Math.max(value, min), max);
3
+
4
+ const lightnessToGrayHex = (lightness: number) => {
5
+ const channel = Math.round(clamp(lightness) * 255)
6
+ .toString(16)
7
+ .padStart(2, "0");
8
+ return `#${channel}${channel}${channel}`;
9
+ };
10
+
11
+ export const buildGreyscalePalette = (
12
+ seriesCount: number,
13
+ isDarkMode: boolean,
14
+ ) => {
15
+ if (seriesCount <= 0) return [];
16
+ if (seriesCount === 1) {
17
+ return [lightnessToGrayHex(isDarkMode ? 0.78 : 0.5)];
18
+ }
19
+
20
+ const start = isDarkMode ? 0.95 : 0.8;
21
+ const end = isDarkMode ? 0.55 : 0.15;
22
+ const step = (end - start) / (seriesCount - 1);
23
+
24
+ return Array.from({ length: seriesCount }, (_, index) =>
25
+ lightnessToGrayHex(start + step * index),
26
+ );
27
+ };
@@ -0,0 +1,191 @@
1
+ "use client";
2
+
3
+ import { useMemo, type ReactNode } from "react";
4
+ import { useTheme } from "next-themes";
5
+ import {
6
+ ResponsiveContainer,
7
+ LineChart as RechartsLineChart,
8
+ Line,
9
+ BarChart as RechartsBarChart,
10
+ Bar,
11
+ CartesianGrid,
12
+ Tooltip,
13
+ XAxis,
14
+ YAxis,
15
+ Label,
16
+ Legend,
17
+ } from "recharts";
18
+
19
+ import type { InconvoChartData, InconvoChartType } from "~/lib/inconvo/types";
20
+ import { buildGreyscalePalette } from "~/components/assistant-ui/tools/inconvo-chart-colors";
21
+
22
+ interface InconvoChartProps {
23
+ data: InconvoChartData;
24
+ variant: InconvoChartType;
25
+ xLabel?: string;
26
+ yLabel?: string;
27
+ title?: string;
28
+ }
29
+
30
+ const ChartScaffold = ({
31
+ children,
32
+ axisColor,
33
+ textColor,
34
+ xLabel,
35
+ yLabel,
36
+ labelCount,
37
+ }: {
38
+ children: ReactNode;
39
+ axisColor: string;
40
+ textColor: string;
41
+ xLabel?: string;
42
+ yLabel?: string;
43
+ labelCount: number;
44
+ }) => (
45
+ <>
46
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
47
+ <XAxis
48
+ dataKey="name"
49
+ stroke={axisColor}
50
+ tick={{ fill: axisColor, fontSize: 12 }}
51
+ angle={-30}
52
+ textAnchor="end"
53
+ interval={labelCount > 12 ? "preserveStartEnd" : 0}
54
+ >
55
+ {xLabel ? (
56
+ <Label
57
+ position="bottom"
58
+ offset={24}
59
+ style={{
60
+ fill: axisColor,
61
+ textAnchor: "middle",
62
+ }}
63
+ value={xLabel}
64
+ />
65
+ ) : null}
66
+ </XAxis>
67
+ <YAxis width={80} stroke={axisColor} tick={{ fill: axisColor, fontSize: 12 }}>
68
+ {yLabel ? (
69
+ <Label
70
+ angle={-90}
71
+ position="insideLeft"
72
+ style={{
73
+ fill: axisColor,
74
+ textAnchor: "middle",
75
+ }}
76
+ value={yLabel}
77
+ />
78
+ ) : null}
79
+ </YAxis>
80
+ <Tooltip
81
+ contentStyle={{
82
+ backgroundColor: "var(--card)",
83
+ border: "1px solid var(--border)",
84
+ borderRadius: 8,
85
+ color: textColor,
86
+ }}
87
+ labelStyle={{
88
+ color: textColor,
89
+ fontWeight: 600,
90
+ }}
91
+ />
92
+ <Legend
93
+ verticalAlign="top"
94
+ align="right"
95
+ wrapperStyle={{
96
+ color: axisColor,
97
+ paddingBottom: "4px",
98
+ }}
99
+ />
100
+ {children}
101
+ </>
102
+ );
103
+
104
+ export const InconvoChart = ({
105
+ data,
106
+ variant,
107
+ xLabel,
108
+ yLabel,
109
+ }: InconvoChartProps) => {
110
+ const { resolvedTheme } = useTheme();
111
+
112
+ const chartData = useMemo(() => {
113
+ return data.labels.map((label, index) => {
114
+ const row: { name: string; [key: string]: string | number } = {
115
+ name: label,
116
+ };
117
+ data.datasets.forEach((dataset) => {
118
+ row[dataset.name] = dataset.values[index] ?? 0;
119
+ });
120
+ return row;
121
+ });
122
+ }, [data]);
123
+
124
+ const palette = useMemo(() => {
125
+ const isDarkMode = resolvedTheme === "dark";
126
+ return buildGreyscalePalette(data.datasets.length, isDarkMode);
127
+ }, [data.datasets.length, resolvedTheme]);
128
+
129
+ const axisColor = "var(--muted-foreground)";
130
+ const textColor = "var(--foreground)";
131
+ const margins = { top: 20, right: 30, bottom: xLabel ? 80 : 40, left: 20 };
132
+
133
+ const renderLines = () =>
134
+ data.datasets.map((dataset, index) => {
135
+ const stroke = palette[index] ?? "var(--chart-series-primary)";
136
+ return (
137
+ <Line
138
+ key={dataset.name}
139
+ type="monotone"
140
+ dataKey={dataset.name}
141
+ stroke={stroke}
142
+ strokeWidth={2}
143
+ dot={{ r: 3, strokeWidth: 2, stroke, fill: "var(--card)" }}
144
+ activeDot={{ r: 5, strokeWidth: 2, stroke, fill: stroke }}
145
+ />
146
+ );
147
+ });
148
+
149
+ const renderBars = () =>
150
+ data.datasets.map((dataset, index) => (
151
+ <Bar
152
+ key={dataset.name}
153
+ dataKey={dataset.name}
154
+ fill={palette[index] ?? "var(--chart-series-primary)"}
155
+ radius={[4, 4, 0, 0]}
156
+ maxBarSize={48}
157
+ />
158
+ ));
159
+
160
+ return (
161
+ <div className="flex w-full flex-col gap-4 text-foreground">
162
+ <ResponsiveContainer width="100%" height={400}>
163
+ {variant === "line" ? (
164
+ <RechartsLineChart data={chartData} margin={margins}>
165
+ <ChartScaffold
166
+ axisColor={axisColor}
167
+ textColor={textColor}
168
+ xLabel={xLabel}
169
+ yLabel={yLabel}
170
+ labelCount={data.labels.length}
171
+ >
172
+ {renderLines()}
173
+ </ChartScaffold>
174
+ </RechartsLineChart>
175
+ ) : (
176
+ <RechartsBarChart data={chartData} margin={margins}>
177
+ <ChartScaffold
178
+ axisColor={axisColor}
179
+ textColor={textColor}
180
+ xLabel={xLabel}
181
+ yLabel={yLabel}
182
+ labelCount={data.labels.length}
183
+ >
184
+ {renderBars()}
185
+ </ChartScaffold>
186
+ </RechartsBarChart>
187
+ )}
188
+ </ResponsiveContainer>
189
+ </div>
190
+ );
191
+ };
@@ -0,0 +1,210 @@
1
+ "use client";
2
+
3
+ import {
4
+ flexRender,
5
+ getCoreRowModel,
6
+ getSortedRowModel,
7
+ useReactTable,
8
+ type ColumnDef,
9
+ type SortingState,
10
+ } from "@tanstack/react-table";
11
+ import { useEffect, useMemo, useRef, useState } from "react";
12
+
13
+ import { Button } from "~/components/ui/button";
14
+ import { cn } from "~/lib/utils";
15
+
16
+ type RowData = Record<string, string>;
17
+
18
+ interface DataTableProps {
19
+ head: string[];
20
+ body: string[][];
21
+ }
22
+
23
+ export const DataTable = ({ head, body }: DataTableProps) => {
24
+ const [sorting, setSorting] = useState<SortingState>([]);
25
+ const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
26
+ const [columnMenuOpen, setColumnMenuOpen] = useState(false);
27
+ const columnMenuRef = useRef<HTMLDivElement | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (!columnMenuOpen) return;
31
+
32
+ const handleClick = (event: MouseEvent) => {
33
+ if (
34
+ columnMenuRef.current &&
35
+ !columnMenuRef.current.contains(event.target as Node)
36
+ ) {
37
+ setColumnMenuOpen(false);
38
+ }
39
+ };
40
+
41
+ document.addEventListener("mousedown", handleClick);
42
+ return () => document.removeEventListener("mousedown", handleClick);
43
+ }, [columnMenuOpen]);
44
+
45
+ const data = useMemo<RowData[]>(() => {
46
+ return body.map((row) => {
47
+ return head.reduce((acc, header, index) => {
48
+ acc[header] = row[index] ?? "";
49
+ return acc;
50
+ }, {} as RowData);
51
+ });
52
+ }, [body, head]);
53
+
54
+ const columns = useMemo<ColumnDef<RowData>[]>(
55
+ () =>
56
+ head
57
+ .filter((header) => !hiddenColumns.has(header))
58
+ .map((header) => ({
59
+ accessorKey: header,
60
+ header,
61
+ cell: (info) => info.getValue(),
62
+ })),
63
+ [head, hiddenColumns],
64
+ );
65
+
66
+ const table = useReactTable({
67
+ data,
68
+ columns,
69
+ state: { sorting },
70
+ onSortingChange: setSorting,
71
+ getCoreRowModel: getCoreRowModel(),
72
+ getSortedRowModel: getSortedRowModel(),
73
+ });
74
+
75
+ const toggleColumnVisibility = (columnName: string) => {
76
+ setHiddenColumns((prev) => {
77
+ const next = new Set(prev);
78
+ if (next.has(columnName)) {
79
+ next.delete(columnName);
80
+ } else {
81
+ next.add(columnName);
82
+ }
83
+ return next;
84
+ });
85
+ };
86
+
87
+ const resetColumns = () => {
88
+ setHiddenColumns(new Set());
89
+ };
90
+
91
+ const allColumnsHidden = hiddenColumns.size === head.length;
92
+
93
+ return (
94
+ <div className="space-y-4 text-sm text-foreground">
95
+ <div className="flex justify-end">
96
+ <div ref={columnMenuRef} className="relative inline-flex">
97
+ <Button
98
+ type="button"
99
+ variant="outline"
100
+ size="sm"
101
+ onClick={() => setColumnMenuOpen((prev) => !prev)}
102
+ className="inline-flex items-center gap-2"
103
+ >
104
+ Columns
105
+ {hiddenColumns.size > 0 ? (
106
+ <span className="rounded-full bg-secondary px-2 py-0.5 text-xs font-semibold text-secondary-foreground">
107
+ {hiddenColumns.size}
108
+ </span>
109
+ ) : null}
110
+ </Button>
111
+
112
+ {columnMenuOpen ? (
113
+ <div className="absolute right-0 z-10 mt-2 w-64 rounded-lg border bg-popover text-popover-foreground shadow-lg">
114
+ <div className="border-b px-4 py-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
115
+ Column visibility
116
+ </div>
117
+ <div className="max-h-64 overflow-y-auto py-1">
118
+ {head.map((columnName) => {
119
+ const isVisible = !hiddenColumns.has(columnName);
120
+ return (
121
+ <button
122
+ key={columnName}
123
+ type="button"
124
+ onClick={() => toggleColumnVisibility(columnName)}
125
+ className="flex w-full items-center gap-3 px-4 py-2 text-left text-sm hover:bg-muted"
126
+ >
127
+ <span
128
+ className={cn(
129
+ "flex h-4 w-4 items-center justify-center rounded border text-[0.6rem] font-bold transition",
130
+ isVisible
131
+ ? "border-primary bg-primary text-primary-foreground"
132
+ : "border-border text-transparent",
133
+ )}
134
+ >
135
+
136
+ </span>
137
+ <span className="flex-1 truncate">{columnName}</span>
138
+ </button>
139
+ );
140
+ })}
141
+ </div>
142
+ </div>
143
+ ) : null}
144
+ </div>
145
+ </div>
146
+
147
+ {allColumnsHidden ? (
148
+ <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 px-6 py-12 text-center">
149
+ <h3 className="mb-2 text-base font-semibold">All columns hidden</h3>
150
+ <p className="mb-4 text-sm text-muted-foreground">
151
+ Show at least one column to view the table.
152
+ </p>
153
+ <Button variant="default" size="sm" onClick={resetColumns}>
154
+ Show all columns
155
+ </Button>
156
+ </div>
157
+ ) : (
158
+ <div className="max-h-[520px] overflow-auto rounded-xl border bg-card">
159
+ <table className="w-full min-w-[480px] border-collapse text-sm">
160
+ <thead className="sticky top-0 bg-card">
161
+ {table.getHeaderGroups().map((headerGroup) => (
162
+ <tr key={headerGroup.id} className="border-b border-border">
163
+ {headerGroup.headers.map((header) => {
164
+ const isSorted = header.column.getIsSorted();
165
+ return (
166
+ <th
167
+ key={header.id}
168
+ onClick={header.column.getToggleSortingHandler()}
169
+ className={cn(
170
+ "px-4 py-3 text-left font-semibold text-muted-foreground",
171
+ header.column.getCanSort() && "cursor-pointer",
172
+ )}
173
+ >
174
+ <span className="inline-flex items-center gap-2">
175
+ {flexRender(
176
+ header.column.columnDef.header,
177
+ header.getContext(),
178
+ )}
179
+ {isSorted ? (
180
+ <span className="text-xs">
181
+ {isSorted === "asc" ? "↓" : "↑"}
182
+ </span>
183
+ ) : null}
184
+ </span>
185
+ </th>
186
+ );
187
+ })}
188
+ </tr>
189
+ ))}
190
+ </thead>
191
+ <tbody>
192
+ {table.getRowModel().rows.map((row) => (
193
+ <tr
194
+ key={row.id}
195
+ className="border-b border-border last:border-0 hover:bg-muted/40"
196
+ >
197
+ {row.getVisibleCells().map((cell) => (
198
+ <td key={cell.id} className="px-4 py-3 text-sm text-foreground">
199
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
200
+ </td>
201
+ ))}
202
+ </tr>
203
+ ))}
204
+ </tbody>
205
+ </table>
206
+ </div>
207
+ )}
208
+ </div>
209
+ );
210
+ };
@@ -0,0 +1,7 @@
1
+ "use client";
2
+
3
+ import { MessageDataAnalystTool } from "~/components/assistant-ui/tools/message-data-analyst-tool";
4
+
5
+ export const InconvoTools = () => {
6
+ return <MessageDataAnalystTool />;
7
+ };
@@ -0,0 +1,208 @@
1
+ "use client";
2
+
3
+ import { AlertCircle, Loader2 } from "lucide-react";
4
+ import {
5
+ makeAssistantToolUI,
6
+ type ToolCallMessagePartComponent,
7
+ } from "@assistant-ui/react";
8
+ import type { ReactNode } from "react";
9
+
10
+ import { InconvoChart } from "~/components/assistant-ui/tools/inconvo-chart";
11
+ import { DataTable } from "~/components/assistant-ui/tools/inconvo-data-table";
12
+ import {
13
+ type InconvoResponse,
14
+ parseInconvoResponse,
15
+ } from "~/lib/inconvo/types";
16
+
17
+ interface MessageDataAnalystArgs {
18
+ conversationId: string;
19
+ message: string;
20
+ }
21
+
22
+ type MessageDataAnalystResult = string | InconvoResponse;
23
+
24
+ const MessageDataAnalystToolRender: ToolCallMessagePartComponent<
25
+ MessageDataAnalystArgs,
26
+ MessageDataAnalystResult
27
+ > = ({ result, status }) => {
28
+ if (status?.type === "running" || !result) {
29
+ return (
30
+ <ToolCard>
31
+ <ToolCardHeader
32
+ title="Contacting data analyst"
33
+ description="Waiting for the data analyst to send a reply..."
34
+ />
35
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
36
+ <Loader2 className="size-4 animate-spin" />
37
+ <span>Fetching details</span>
38
+ </div>
39
+ </ToolCard>
40
+ );
41
+ }
42
+
43
+ const parsed = parseInconvoResponse(result);
44
+
45
+ if (!parsed) {
46
+ return (
47
+ <ToolCard variant="error">
48
+ <ToolCardHeader
49
+ variant="error"
50
+ title="Unable to read analyst response"
51
+ description="The response couldn't be parsed. Showing the raw payload below."
52
+ />
53
+ <RawPayload payload={typeof result === "string" ? result : JSON.stringify(result, null, 2)} />
54
+ </ToolCard>
55
+ );
56
+ }
57
+
58
+ if (status?.type === "incomplete") {
59
+ return (
60
+ <ToolCard variant="error">
61
+ <ToolCardHeader
62
+ variant="error"
63
+ title="Analyst conversation interrupted"
64
+ description={
65
+ status.reason === "cancelled"
66
+ ? "The tool call was cancelled before it completed."
67
+ : "The tool call ended unexpectedly."
68
+ }
69
+ />
70
+ <RawPayload payload={parsed.message} />
71
+ </ToolCard>
72
+ );
73
+ }
74
+
75
+ switch (parsed.type) {
76
+ case "text":
77
+ return (
78
+ <ToolCard>
79
+ <ToolCardHeader
80
+ title="Analyst response"
81
+ description="Plain text answer from the analyst"
82
+ />
83
+ <TextResponse content={parsed.message} />
84
+ </ToolCard>
85
+ );
86
+ case "chart":
87
+ if (!parsed.chart) {
88
+ return (
89
+ <ToolCard variant="error">
90
+ <ToolCardHeader
91
+ variant="error"
92
+ title="Missing chart payload"
93
+ description="The analyst referenced a chart but no data was provided."
94
+ />
95
+ </ToolCard>
96
+ );
97
+ }
98
+ return (
99
+ <ToolCard>
100
+ <ToolCardHeader
101
+ title={parsed.chart.title ?? "Chart"}
102
+ description={parsed.message}
103
+ />
104
+ <InconvoChart
105
+ data={parsed.chart.data}
106
+ variant={parsed.chart.type}
107
+ title={parsed.chart.title}
108
+ xLabel={parsed.chart.xLabel}
109
+ yLabel={parsed.chart.yLabel}
110
+ />
111
+ </ToolCard>
112
+ );
113
+ case "table":
114
+ if (!parsed.table) {
115
+ return (
116
+ <ToolCard variant="error">
117
+ <ToolCardHeader
118
+ variant="error"
119
+ title="Missing table payload"
120
+ description="The analyst referenced a table but no data was provided."
121
+ />
122
+ </ToolCard>
123
+ );
124
+ }
125
+ return (
126
+ <ToolCard>
127
+ <ToolCardHeader
128
+ title="Tabular result"
129
+ description={parsed.message}
130
+ />
131
+ <DataTable head={parsed.table.head} body={parsed.table.body} />
132
+ </ToolCard>
133
+ );
134
+ default:
135
+ return (
136
+ <ToolCard>
137
+ <ToolCardHeader
138
+ title="Analyst response"
139
+ description="Response received, but no renderer is configured for this type yet."
140
+ />
141
+ <RawPayload payload={JSON.stringify(parsed, null, 2)} />
142
+ </ToolCard>
143
+ );
144
+ }
145
+ };
146
+
147
+ export const MessageDataAnalystTool = makeAssistantToolUI<
148
+ MessageDataAnalystArgs,
149
+ MessageDataAnalystResult
150
+ >({
151
+ toolName: "message_data_analyst",
152
+ render: MessageDataAnalystToolRender,
153
+ });
154
+
155
+ const ToolCard = ({
156
+ children,
157
+ variant = "default",
158
+ }: {
159
+ children: ReactNode;
160
+ variant?: "default" | "error";
161
+ }) => {
162
+ const borderClasses =
163
+ variant === "error"
164
+ ? "border-red-500/40 bg-red-500/5 dark:bg-red-500/10"
165
+ : "border-border bg-muted/50 dark:bg-muted/20";
166
+
167
+ return (
168
+ <div
169
+ className={`aui-tool-card mb-4 flex w-full flex-col gap-4 rounded-2xl border px-5 py-4 text-sm ${borderClasses}`}
170
+ >
171
+ {children}
172
+ </div>
173
+ );
174
+ };
175
+
176
+ const ToolCardHeader = ({
177
+ title,
178
+ description,
179
+ variant = "default",
180
+ }: {
181
+ title: string;
182
+ description?: string;
183
+ variant?: "default" | "error";
184
+ }) => (
185
+ <div className="flex gap-2">
186
+ {variant === "error" ? (
187
+ <AlertCircle className="mt-0.5 size-4 text-red-500" />
188
+ ) : null}
189
+ <div className="space-y-1">
190
+ <p className="text-sm font-semibold text-foreground">{title}</p>
191
+ {description ? (
192
+ <p className="text-sm text-muted-foreground">{description}</p>
193
+ ) : null}
194
+ </div>
195
+ </div>
196
+ );
197
+
198
+ const TextResponse = ({ content }: { content: string }) => (
199
+ <p className="whitespace-pre-wrap text-base leading-6 text-foreground">
200
+ {content}
201
+ </p>
202
+ );
203
+
204
+ const RawPayload = ({ payload }: { payload: string }) => (
205
+ <pre className="overflow-auto rounded-lg bg-background/60 p-3 text-xs text-muted-foreground">
206
+ {payload}
207
+ </pre>
208
+ );