@cosmoops/connect-sdk 0.7.0 → 0.9.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/CHANGELOG.md +165 -0
- package/README.md +273 -91
- package/dist/cjs/client/components/access-gate.js +39 -4
- package/dist/cjs/client/components/ai-format.js +135 -0
- package/dist/cjs/client/components/ai-widgets.js +232 -0
- package/dist/cjs/client/components/app-shell.js +7 -6
- package/dist/cjs/client/components/ask-ai.js +334 -0
- package/dist/cjs/client/components/nav.js +1 -1
- package/dist/cjs/client/components/side-nav.js +1 -1
- package/dist/cjs/client/components/user-menu.js +28 -5
- package/dist/cjs/client/components/workspace-type-gate.js +48 -0
- package/dist/cjs/client/index.js +31 -3
- package/dist/cjs/client/provider.js +45 -2
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +43 -0
- package/dist/cjs/server/http.js +46 -24
- package/dist/cjs/server/resources/entitlements.js +41 -0
- package/dist/cjs/server/resources/identity.js +2 -0
- package/dist/esm/client/components/access-gate.js +40 -5
- package/dist/esm/client/components/ai-format.js +129 -0
- package/dist/esm/client/components/ai-widgets.js +223 -0
- package/dist/esm/client/components/app-shell.js +6 -5
- package/dist/esm/client/components/ask-ai.js +331 -0
- package/dist/esm/client/components/nav.js +1 -1
- package/dist/esm/client/components/side-nav.js +1 -1
- package/dist/esm/client/components/user-menu.js +28 -5
- package/dist/esm/client/components/workspace-type-gate.js +45 -0
- package/dist/esm/client/index.js +13 -2
- package/dist/esm/client/provider.js +43 -2
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +43 -0
- package/dist/esm/server/http.js +46 -24
- package/dist/esm/server/resources/entitlements.js +41 -0
- package/dist/esm/server/resources/identity.js +2 -0
- package/dist/theme.css +87 -0
- package/dist/types/client/components/access-gate.d.ts +17 -3
- package/dist/types/client/components/ai-format.d.ts +24 -0
- package/dist/types/client/components/ai-widgets.d.ts +42 -0
- package/dist/types/client/components/app-shell.d.ts +21 -5
- package/dist/types/client/components/ask-ai.d.ts +73 -0
- package/dist/types/client/components/nav.d.ts +3 -3
- package/dist/types/client/components/side-nav.d.ts +1 -1
- package/dist/types/client/components/user-menu.d.ts +2 -1
- package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
- package/dist/types/client/index.d.ts +10 -3
- package/dist/types/client/provider.d.ts +44 -1
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/http.d.ts +6 -1
- package/dist/types/server/resources/entitlements.d.ts +33 -1
- package/dist/types/types.d.ts +174 -0
- package/package.json +8 -2
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { Button } from "@heroui/react";
|
|
5
|
+
import { ArrowDown, ArrowUp, ChevronsUpDown, Download, ExternalLink, FileText, Minus, TrendingDown, TrendingUp } from "lucide-react";
|
|
6
|
+
import { AI_EMPTY_VALUE, AI_TONE_CHIP, AI_TONE_DOT, aiStatusTone, formatAiValue, isNumericAiFormat, } from "./ai-format.js";
|
|
7
|
+
/**
|
|
8
|
+
* What Ask AI draws.
|
|
9
|
+
*
|
|
10
|
+
* Seven widgets, one per data shape, rendered from the model's own tool call -
|
|
11
|
+
* see `AiToolName`. Everything here is HeroUI semantic tokens and inline SVG:
|
|
12
|
+
* **no charting library, no table library, no new dependency**, because this
|
|
13
|
+
* package ships to partners and a chart library is not a reasonable thing to
|
|
14
|
+
* make every application platform install for one panel.
|
|
15
|
+
*
|
|
16
|
+
* The hand-rolled chart is the deliberate part of that. A 400px side panel does
|
|
17
|
+
* not need pan, zoom, brushes or animation; it needs axis labels a human can
|
|
18
|
+
* read and a line that goes where the numbers do. That is ~80 lines of SVG.
|
|
19
|
+
*/
|
|
20
|
+
/** The four small panels share one frame, so an answer with two of them reads as one object. */
|
|
21
|
+
function Panel({ title, children }) {
|
|
22
|
+
return (_jsxs("figure", { className: "my-3 rounded-lg border border-border p-3", children: [title && _jsx("figcaption", { className: "mb-2 text-xs font-semibold", children: title }), children] }));
|
|
23
|
+
}
|
|
24
|
+
const TONE_TEXT = {
|
|
25
|
+
neutral: "text-muted",
|
|
26
|
+
positive: "text-success",
|
|
27
|
+
warning: "text-warning",
|
|
28
|
+
danger: "text-danger",
|
|
29
|
+
};
|
|
30
|
+
function StatusChip({ value }) {
|
|
31
|
+
return (_jsx("span", { className: `inline-block rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AI_TONE_CHIP[aiStatusTone(value)]}`, children: String(value) }));
|
|
32
|
+
}
|
|
33
|
+
/* ------------------------------------------------------------------ table */
|
|
34
|
+
/**
|
|
35
|
+
* `showTable`. Sorting and CSV are what a table is *for* - once an answer is
|
|
36
|
+
* tabular the next thing anyone wants is to reorder it or take it elsewhere,
|
|
37
|
+
* and a markdown table can do neither. Both work off the raw values, so
|
|
38
|
+
* "₹12,500" sorts as 12500 and the export carries numbers a spreadsheet can add.
|
|
39
|
+
*/
|
|
40
|
+
export function AiTable({ title, columns, rows, caption }) {
|
|
41
|
+
// By column *index*: rows are positional (see `AiTableInput`), so the index
|
|
42
|
+
// is the only identity a column has.
|
|
43
|
+
const [sort, setSort] = useState(null);
|
|
44
|
+
const sorted = useMemo(() => {
|
|
45
|
+
if (!sort)
|
|
46
|
+
return rows;
|
|
47
|
+
const direction = sort.direction === "asc" ? 1 : -1;
|
|
48
|
+
return [...rows].sort((a, b) => {
|
|
49
|
+
const left = a[sort.column];
|
|
50
|
+
const right = b[sort.column];
|
|
51
|
+
// Missing values sink in both directions. The alternative is empty rows
|
|
52
|
+
// migrating to the top when you reverse a column, which reads as data
|
|
53
|
+
// appearing out of nowhere.
|
|
54
|
+
if (left === null || left === undefined)
|
|
55
|
+
return 1;
|
|
56
|
+
if (right === null || right === undefined)
|
|
57
|
+
return -1;
|
|
58
|
+
if (typeof left === "number" && typeof right === "number")
|
|
59
|
+
return (left - right) * direction;
|
|
60
|
+
return String(left).localeCompare(String(right)) * direction;
|
|
61
|
+
});
|
|
62
|
+
}, [rows, sort]);
|
|
63
|
+
const downloadCsv = () => {
|
|
64
|
+
const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
|
|
65
|
+
const csv = [
|
|
66
|
+
columns.map((column) => escape(column.label)).join(","),
|
|
67
|
+
...sorted.map((row) => columns.map((_column, index) => escape(row[index])).join(",")),
|
|
68
|
+
].join("\n");
|
|
69
|
+
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
|
70
|
+
const link = document.createElement("a");
|
|
71
|
+
link.href = url;
|
|
72
|
+
link.download = `${(title ?? "table").toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}.csv`;
|
|
73
|
+
link.click();
|
|
74
|
+
URL.revokeObjectURL(url);
|
|
75
|
+
};
|
|
76
|
+
const toggleSort = (column) => setSort((current) => current?.column === column
|
|
77
|
+
? current.direction === "asc"
|
|
78
|
+
? { column, direction: "desc" }
|
|
79
|
+
: null
|
|
80
|
+
: { column, direction: "asc" });
|
|
81
|
+
return (_jsxs("figure", { className: "my-3 overflow-hidden rounded-lg border border-border", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border bg-surface-secondary px-3 py-2", children: [_jsx("figcaption", { className: "flex-1 truncate text-xs font-semibold", children: title ?? "Results" }), _jsxs("span", { className: "text-[11px] text-muted", children: [rows.length, " rows"] }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Download as CSV", onPress: downloadCsv, children: _jsx(Download, { className: "size-3.5" }) })] }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-xs", children: [_jsx("thead", { children: _jsx("tr", { className: "border-b border-border", children: columns.map((column, columnIndex) => {
|
|
82
|
+
const isSorted = sort?.column === columnIndex;
|
|
83
|
+
const Icon = !isSorted ? ChevronsUpDown : sort.direction === "asc" ? ArrowUp : ArrowDown;
|
|
84
|
+
return (_jsx("th", { scope: "col", className: "p-0", children: _jsxs("button", { type: "button", onClick: () => toggleSort(columnIndex), "aria-label": `Sort by ${column.label}`, className: `flex w-full items-center gap-1 px-2.5 py-1.5 font-medium whitespace-nowrap hover:bg-surface-secondary ${isNumericAiFormat(column.format) ? "justify-end" : "justify-start"}`, children: [column.label, _jsx(Icon, { className: `size-3 shrink-0 ${isSorted ? "text-foreground" : "text-muted/60"}` })] }) }, columnIndex));
|
|
85
|
+
}) }) }), _jsxs("tbody", { children: [sorted.map((row, index) => (_jsx("tr", { className: "border-b border-border/60 last:border-b-0", children: columns.map((column, columnIndex) => {
|
|
86
|
+
const value = row[columnIndex] ?? null;
|
|
87
|
+
return (_jsx("td", { className: `px-2.5 py-1.5 align-top ${isNumericAiFormat(column.format) ? "text-end tabular-nums" : "text-start"}`, children: column.format === "status" && value !== null ? (_jsx(StatusChip, { value: value })) : (_jsx("span", { className: value === null ? "text-muted" : undefined, children: formatAiValue(value, column.format) })) }, columnIndex));
|
|
88
|
+
}) }, index))), sorted.length === 0 && (_jsx("tr", { children: _jsx("td", { colSpan: columns.length, className: "px-2.5 py-3 text-center text-muted", children: AI_EMPTY_VALUE }) }))] })] }) }), caption && _jsx("p", { className: "border-t border-border px-3 py-1.5 text-[11px] text-muted", children: caption })] }));
|
|
89
|
+
}
|
|
90
|
+
/* ------------------------------------------------------------------ chart */
|
|
91
|
+
/**
|
|
92
|
+
* The theme's categorical ramp. Series identity is carried by the legend as
|
|
93
|
+
* well as the hue, never by hue alone.
|
|
94
|
+
*/
|
|
95
|
+
const SERIES_COLORS = [
|
|
96
|
+
"var(--chart-cat-1, var(--accent))",
|
|
97
|
+
"var(--chart-cat-2, var(--warning))",
|
|
98
|
+
"var(--chart-cat-3, var(--success))",
|
|
99
|
+
"var(--chart-cat-4, var(--danger))",
|
|
100
|
+
"var(--chart-cat-5, var(--muted))",
|
|
101
|
+
];
|
|
102
|
+
const VIEW_W = 320;
|
|
103
|
+
const VIEW_H = 140;
|
|
104
|
+
const PAD = { top: 8, right: 8, bottom: 20, left: 44 };
|
|
105
|
+
/**
|
|
106
|
+
* `showChart`. Cartesian kinds are drawn as SVG against a fixed viewBox and
|
|
107
|
+
* scaled by the browser, which is why there is no resize observer here: the
|
|
108
|
+
* chart is resolution-independent, so "responsive" costs nothing at runtime.
|
|
109
|
+
*
|
|
110
|
+
* `pie` is deliberately *not* a pie. In a 400px column a pie plus its legend
|
|
111
|
+
* is smaller than the labels it needs, and reading angles is worse than
|
|
112
|
+
* reading lengths at any size - so proportions render as a labelled bar list,
|
|
113
|
+
* which answers the same question and stays legible.
|
|
114
|
+
*/
|
|
115
|
+
export function AiChart({ title, kind, xLabel, yLabel, valueFormat, series, caption }) {
|
|
116
|
+
const format = (value) => formatAiValue(value, valueFormat);
|
|
117
|
+
const axis = series[0]?.points.map((point) => point.x) ?? [];
|
|
118
|
+
// Which x the pointer is nearest. An SVG chart is an interactive chart:
|
|
119
|
+
// without this the exact values are unreadable, because a 140px-tall plot
|
|
120
|
+
// can only carry three axis labels.
|
|
121
|
+
const [hovered, setHovered] = useState(null);
|
|
122
|
+
if (kind === "pie") {
|
|
123
|
+
const slices = series[0]?.points ?? [];
|
|
124
|
+
// Guards a divide-by-zero on an all-zero set, which would otherwise make
|
|
125
|
+
// every bar `NaN%` wide.
|
|
126
|
+
const total = slices.reduce((sum, point) => sum + (point.y ?? 0), 0) || 1;
|
|
127
|
+
return (_jsxs(Panel, { title: title, children: [_jsx("ul", { className: "space-y-1.5", children: slices.map((slice, index) => (_jsxs("li", { className: "text-xs", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2", children: [_jsx("span", { className: "truncate", children: slice.x }), _jsxs("span", { className: "shrink-0 tabular-nums text-muted", children: [format(slice.y ?? 0), " \u00B7 ", Math.round(((slice.y ?? 0) / total) * 100), "%"] })] }), _jsx("div", { className: "mt-0.5 h-1.5 overflow-hidden rounded-full bg-surface-tertiary", children: _jsx("div", { className: "h-full rounded-full", style: {
|
|
128
|
+
width: `${((slice.y ?? 0) / total) * 100}%`,
|
|
129
|
+
backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length],
|
|
130
|
+
} }) })] }, slice.x))) }), caption && _jsx("p", { className: "mt-2 text-[11px] text-muted", children: caption })] }));
|
|
131
|
+
}
|
|
132
|
+
const values = series.flatMap((entry) => entry.points.map((point) => point.y)).filter((y) => y !== null);
|
|
133
|
+
// A flat series would give a zero-height range and divide by zero; padding
|
|
134
|
+
// the top by 1 draws it as a line across the middle instead of NaN.
|
|
135
|
+
const max = values.length ? Math.max(...values, 0) : 1;
|
|
136
|
+
const min = values.length ? Math.min(...values, 0) : 0;
|
|
137
|
+
const range = max - min || 1;
|
|
138
|
+
const plotW = VIEW_W - PAD.left - PAD.right;
|
|
139
|
+
const plotH = VIEW_H - PAD.top - PAD.bottom;
|
|
140
|
+
const xAt = (index) => PAD.left + (axis.length <= 1 ? plotW / 2 : (index / (axis.length - 1)) * plotW);
|
|
141
|
+
const yAt = (value) => PAD.top + plotH - ((value - min) / range) * plotH;
|
|
142
|
+
/** Ticks at the extremes and the midpoint - three labels is all a 140px-tall plot can carry. */
|
|
143
|
+
const yTicks = [min, min + range / 2, max];
|
|
144
|
+
// First, middle and last x label only, for the same reason.
|
|
145
|
+
const xTicks = axis.length <= 3 ? axis.map((_, i) => i) : [0, Math.floor((axis.length - 1) / 2), axis.length - 1];
|
|
146
|
+
return (_jsxs(Panel, { title: title, children: [_jsxs("div", { className: "relative", children: [hovered !== null && (
|
|
147
|
+
// Positioned as a percentage of the plot so it tracks the mark at any
|
|
148
|
+
// rendered width - the SVG scales, and this has to scale with it.
|
|
149
|
+
_jsxs("div", { className: "pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-border bg-overlay px-2 py-1 text-[10px] shadow-lg", style: { left: `${(xAt(hovered) / VIEW_W) * 100}%` }, children: [_jsx("p", { className: "font-medium", children: axis[hovered] }), series.map((entry, index) => {
|
|
150
|
+
const value = entry.points[hovered]?.y;
|
|
151
|
+
return (_jsxs("p", { className: "flex items-center gap-1 text-muted", children: [_jsx("span", { className: "size-1.5 shrink-0 rounded-full", style: { backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length] }, "aria-hidden": true }), _jsx("span", { className: "tabular-nums", children: value === null || value === undefined ? AI_EMPTY_VALUE : format(value) })] }, entry.name));
|
|
152
|
+
})] })), _jsxs("svg", { viewBox: `0 0 ${VIEW_W} ${VIEW_H}`, className: "w-full", role: "img", "aria-label": title ?? "Chart", onMouseLeave: () => setHovered(null), children: [yTicks.map((tick) => (_jsxs("g", { children: [_jsx("line", { x1: PAD.left, x2: VIEW_W - PAD.right, y1: yAt(tick), y2: yAt(tick), stroke: "var(--separator)", strokeDasharray: "3 3" }), _jsx("text", { x: PAD.left - 4, y: yAt(tick) + 3, textAnchor: "end", fontSize: "7", fill: "var(--muted)", children: format(tick) })] }, tick))), xTicks.map((index) => (_jsx("text", { x: xAt(index), y: VIEW_H - 6, textAnchor: "middle", fontSize: "7", fill: "var(--muted)", children: axis[index] }, index))), series.map((entry, seriesIndex) => {
|
|
153
|
+
const color = SERIES_COLORS[seriesIndex % SERIES_COLORS.length];
|
|
154
|
+
if (kind === "bar") {
|
|
155
|
+
const slot = plotW / Math.max(axis.length, 1);
|
|
156
|
+
// A 2px surface gap between adjacent series, so two bars in one
|
|
157
|
+
// slot read as two marks rather than one two-toned block.
|
|
158
|
+
const width = Math.max(2, (slot * 0.7) / series.length - (series.length > 1 ? 2 : 0));
|
|
159
|
+
return entry.points.map((point, index) => point.y === null ? null : (_jsx("rect", { x: xAt(index) - (width * series.length) / 2 + seriesIndex * width, y: Math.min(yAt(point.y), yAt(0)), width: width, height: Math.max(1, Math.abs(yAt(point.y) - yAt(0))), fill: color, rx: "3" }, `${entry.name}-${point.x}`)));
|
|
160
|
+
}
|
|
161
|
+
// `null` breaks the path into separate segments rather than drawing
|
|
162
|
+
// a straight line through a gap that has no data in it.
|
|
163
|
+
const segments = [];
|
|
164
|
+
let current = [];
|
|
165
|
+
entry.points.forEach((point, index) => {
|
|
166
|
+
if (point.y === null) {
|
|
167
|
+
if (current.length)
|
|
168
|
+
segments.push(current.join(" "));
|
|
169
|
+
current = [];
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
current.push(`${current.length ? "L" : "M"}${xAt(index)},${yAt(point.y)}`);
|
|
173
|
+
});
|
|
174
|
+
if (current.length)
|
|
175
|
+
segments.push(current.join(" "));
|
|
176
|
+
return (_jsxs("g", { children: [kind === "area" && segments.length === 1 && (_jsx("path", { d: `${segments[0]} L${xAt(entry.points.length - 1)},${yAt(min)} L${xAt(0)},${yAt(min)} Z`, fill: color, fillOpacity: "0.15" })), segments.map((segment) => (_jsx("path", { d: segment, fill: "none", stroke: color, strokeWidth: "1.5", strokeLinejoin: "round" }, segment)))] }, entry.name));
|
|
177
|
+
}), axis.map((label, index) => (_jsx("rect", { x: xAt(index) - plotW / Math.max(axis.length, 1) / 2, y: PAD.top, width: plotW / Math.max(axis.length, 1), height: plotH, fill: "transparent", onMouseEnter: () => setHovered(index) }, label))), hovered !== null && (_jsx("line", { x1: xAt(hovered), x2: xAt(hovered), y1: PAD.top, y2: PAD.top + plotH, stroke: "var(--border)", strokeWidth: "1" }))] })] }), (series.length > 1 || xLabel || yLabel) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted", children: [series.length > 1 &&
|
|
178
|
+
series.map((entry, index) => (_jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx("span", { className: "size-2 rounded-full", style: { backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length] }, "aria-hidden": true }), entry.name] }, entry.name))), yLabel && _jsxs("span", { children: ["y: ", yLabel] }), xLabel && _jsxs("span", { children: ["x: ", xLabel] })] })), caption && _jsx("p", { className: "mt-2 text-[11px] text-muted", children: caption })] }));
|
|
179
|
+
}
|
|
180
|
+
/* ----------------------------------------------------------------- panels */
|
|
181
|
+
/**
|
|
182
|
+
* `showMetrics`. A delta is coloured by `deltaMeaning`, not by its sign -
|
|
183
|
+
* churn rising is bad and revenue rising is good, and painting both green
|
|
184
|
+
* because the arrow points up is exactly the confidently-wrong signal a
|
|
185
|
+
* dashboard must never send. The arrow still shows direction, so meaning and
|
|
186
|
+
* direction stay two separate channels.
|
|
187
|
+
*/
|
|
188
|
+
export function AiMetrics({ title, metrics }) {
|
|
189
|
+
return (_jsx(Panel, { title: title, children: _jsx("div", { className: "grid grid-cols-2 gap-2", children: metrics.map((metric) => {
|
|
190
|
+
const delta = metric.delta ?? null;
|
|
191
|
+
const rising = delta !== null && delta > 0;
|
|
192
|
+
const Arrow = delta === null || delta === 0 ? Minus : rising ? TrendingUp : TrendingDown;
|
|
193
|
+
const good = metric.deltaMeaning === "neutral" || delta === null || delta === 0
|
|
194
|
+
? null
|
|
195
|
+
: metric.deltaMeaning === "up-is-good"
|
|
196
|
+
? rising
|
|
197
|
+
: !rising;
|
|
198
|
+
return (_jsxs("div", { className: "rounded-md bg-surface-secondary p-2.5", children: [_jsx("p", { className: "truncate text-[11px] text-muted", children: metric.label }), _jsx("p", { className: "mt-0.5 text-base font-semibold tabular-nums", children: formatAiValue(metric.value, metric.format) }), delta !== null && (_jsxs("p", { className: `mt-0.5 flex items-center gap-0.5 text-[11px] tabular-nums ${good === null ? "text-muted" : good ? "text-success" : "text-danger"}`, children: [_jsx(Arrow, { className: "size-3", "aria-hidden": true }), formatAiValue(Math.abs(delta), "percent")] })), metric.hint && _jsx("p", { className: "mt-0.5 text-[10px] text-muted", children: metric.hint })] }, metric.label));
|
|
199
|
+
}) }) }));
|
|
200
|
+
}
|
|
201
|
+
/** `showTimeline`: dated events, oldest first, on a single rail. */
|
|
202
|
+
export function AiTimeline({ title, events }) {
|
|
203
|
+
return (_jsx(Panel, { title: title, children: _jsxs("ol", { className: "relative space-y-3 ps-4", children: [_jsx("span", { className: "absolute inset-y-1 start-1 w-px bg-border", "aria-hidden": true }), events.map((event, index) => (_jsxs("li", { className: "relative", children: [_jsx("span", { className: `absolute -start-4 top-1 size-2 rounded-full ring-2 ring-surface ${AI_TONE_DOT[event.tone]}`, "aria-hidden": true }), _jsx("p", { className: "text-[11px] text-muted", children: formatAiValue(event.at, "datetime") }), _jsx("p", { className: "text-xs font-medium", children: event.title }), event.detail && _jsx("p", { className: "text-xs text-muted", children: event.detail })] }, `${event.at}-${index}`)))] }) }));
|
|
204
|
+
}
|
|
205
|
+
/** `showDetails`: one entity's fields, as a definition list rather than a one-row table. */
|
|
206
|
+
export function AiDetails({ title, fields }) {
|
|
207
|
+
return (_jsx(Panel, { title: title, children: _jsx("dl", { className: "divide-y divide-border/60", children: fields.map((field) => (_jsxs("div", { className: "flex gap-3 py-1.5 first:pt-0 last:pb-0", children: [_jsx("dt", { className: "w-2/5 shrink-0 text-xs text-muted", children: field.label }), _jsx("dd", { className: `min-w-0 flex-1 text-xs break-words ${TONE_TEXT[field.tone]}`, children: field.format === "status" && field.value !== null ? (_jsx(StatusChip, { value: field.value })) : (formatAiValue(field.value, field.format)) })] }, field.label))) }) }));
|
|
208
|
+
}
|
|
209
|
+
/** `showSteps`: an ordered procedure. `renderDetail` is how the panel feeds each step back through its markdown renderer. */
|
|
210
|
+
export function AiSteps({ title, steps, renderDetail, }) {
|
|
211
|
+
return (_jsx(Panel, { title: title, children: _jsx("ol", { className: "space-y-2.5", children: steps.map((step, index) => (_jsxs("li", { className: "flex gap-2.5", children: [_jsx("span", { className: "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-accent/12 text-[10px] font-semibold text-accent", children: index + 1 }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "text-xs font-medium", children: step.title }), step.detail && (_jsx("div", { className: "mt-0.5 text-xs text-muted", children: renderDetail ? renderDetail(step.detail) : _jsx("span", { className: "whitespace-pre-wrap", children: step.detail }) }))] })] }, step.title))) }) }));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* `showSources`: what a grounded answer actually rested on. The point of a
|
|
215
|
+
* citation is that it can be opened, so a source with a `uri` is a link and one
|
|
216
|
+
* without is still a named row - never a marker with nothing behind it.
|
|
217
|
+
*/
|
|
218
|
+
export function AiSources({ sources }) {
|
|
219
|
+
return (_jsxs("figure", { className: "my-3 rounded-lg border border-border bg-surface-secondary p-2.5", children: [_jsx("figcaption", { className: "mb-1.5 text-[11px] font-semibold tracking-wide text-muted uppercase", children: "Sources" }), _jsx("ul", { className: "space-y-1.5", children: sources.map((source, index) => {
|
|
220
|
+
const label = (_jsxs(_Fragment, { children: [_jsx(FileText, { className: "mt-0.5 size-3 shrink-0 text-muted", "aria-hidden": true }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-xs font-medium", children: source.title }), source.snippet && _jsxs("span", { className: "mt-0.5 block text-[11px] text-muted italic", children: ["\u201C", source.snippet, "\u201D"] })] })] }));
|
|
221
|
+
return (_jsx("li", { children: source.uri ? (_jsxs("a", { href: source.uri, target: "_blank", rel: "noopener noreferrer", className: "flex gap-1.5 rounded-sm hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus", children: [label, _jsx(ExternalLink, { className: "mt-0.5 size-3 shrink-0 text-muted", "aria-hidden": true })] })) : (_jsx("span", { className: "flex gap-1.5", children: label })) }, `${source.uri ?? source.title}-${index}`));
|
|
222
|
+
}) })] }));
|
|
223
|
+
}
|
|
@@ -6,6 +6,7 @@ import { Menu, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
|
6
6
|
import { useConnect } from "../provider.js";
|
|
7
7
|
import { ConnectAccessGate } from "./access-gate.js";
|
|
8
8
|
import { ConnectAppsGrid } from "./apps-grid.js";
|
|
9
|
+
import { ConnectAskAi } from "./ask-ai.js";
|
|
9
10
|
import { ConnectLoginButton, ConnectRegisterButton } from "./auth-buttons.js";
|
|
10
11
|
import { ConnectLogo } from "./logo.js";
|
|
11
12
|
import { ConnectSideNav, defaultRenderLink } from "./side-nav.js";
|
|
@@ -79,12 +80,12 @@ function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
|
|
|
79
80
|
* "use client";
|
|
80
81
|
* import Link from "next/link";
|
|
81
82
|
* import { usePathname } from "next/navigation";
|
|
82
|
-
* import {
|
|
83
|
+
* import { AppShell } from "@cosmoops/connect-sdk/client";
|
|
83
84
|
* import { LayoutDashboard, Truck } from "lucide-react";
|
|
84
85
|
*
|
|
85
86
|
* export function Shell({ children }: { children: React.ReactNode }) {
|
|
86
87
|
* return (
|
|
87
|
-
* <
|
|
88
|
+
* <AppShell
|
|
88
89
|
* name="Dispatch"
|
|
89
90
|
* icon={<Truck />}
|
|
90
91
|
* activePath={usePathname()}
|
|
@@ -95,7 +96,7 @@ function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
|
|
|
95
96
|
* ]}
|
|
96
97
|
* >
|
|
97
98
|
* {children}
|
|
98
|
-
* </
|
|
99
|
+
* </AppShell>
|
|
99
100
|
* );
|
|
100
101
|
* }
|
|
101
102
|
* ```
|
|
@@ -109,7 +110,7 @@ function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
|
|
|
109
110
|
* so a page with a shell, a plan table and a usage meter still makes one
|
|
110
111
|
* session read.
|
|
111
112
|
*/
|
|
112
|
-
export function
|
|
113
|
+
export function AppShell({ name, icon, homeHref = "/", nav, activePath, renderLink = defaultRenderLink, title, headerActions, sidebarFooter, footer, defaultCollapsed = false, collapsed: controlledCollapsed, onCollapsedChange, collapsible = true, showAppsGrid = true, showAuthButtons = true, theme, onThemeChange, gate = true, gateExemptRoutes, askAi = true, className, contentClassName, children, }) {
|
|
113
114
|
const { session, isLoading } = useConnect();
|
|
114
115
|
const pathname = useActivePath(activePath);
|
|
115
116
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
@@ -150,5 +151,5 @@ export function ConnectAppShell({ name, icon, homeHref = "/", nav, activePath, r
|
|
|
150
151
|
// leaves this icon-only button with no accessible name at all.
|
|
151
152
|
// The wrapper is for trigger elements React Aria cannot drive
|
|
152
153
|
// directly; a HeroUI `Button` is not one.
|
|
153
|
-
_jsxs(Tooltip, { delay: 200, children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar", className: collapsed ? "" : "self-start", onPress: toggleCollapsed, children: collapsed ? _jsx(PanelLeftOpen, { className: "size-4" }) : _jsx(PanelLeftClose, { className: "size-4" }) }), _jsx(Tooltip.Content, { placement: "right", children: collapsed ? "Expand sidebar" : "Collapse sidebar" })] }))] })] }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-72 max-w-[80vw]", children: _jsxs(Drawer.Dialog, { "aria-label": "Navigation menu", children: [_jsx(Drawer.CloseTrigger, {}), _jsx(Drawer.Header, { children: _jsx(Drawer.Heading, { "aria-label": name, children: _jsx(ConnectLogo, { name: name, icon: icon }) }) }), _jsx(Drawer.Body, { children: sideNav(true) }), sidebarFooter && _jsx(Drawer.Footer, { className: "flex flex-col items-stretch gap-3", children: sidebarFooter })] }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-16 shrink-0 items-center gap-3 border-b border-border bg-surface px-4 md:px-6", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", className: "md:hidden", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), typeof title === "string" ? (_jsx("h1", { className: "truncate text-lg font-semibold text-foreground", children: title })) : (title), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [headerActions, showAppsGrid && _jsx(ConnectAppsGrid, {}), _jsx(ConnectUserMenu, { compact: true, theme: theme, onThemeChange: onThemeChange }), showAuthButtons && (_jsxs(_Fragment, { children: [_jsx(ConnectLoginButton, { size: "sm" }), _jsx(ConnectRegisterButton, { size: "sm" })] }))] })] }), _jsx("main", { className: "flex-1 overflow-y-auto", children: _jsxs("div", { className: contentClassName ?? "mx-auto w-full max-w-6xl px-4 py-6 md:px-6 md:py-8", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] })] }));
|
|
154
|
+
_jsxs(Tooltip, { delay: 200, children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar", className: collapsed ? "" : "self-start", onPress: toggleCollapsed, children: collapsed ? _jsx(PanelLeftOpen, { className: "size-4" }) : _jsx(PanelLeftClose, { className: "size-4" }) }), _jsx(Tooltip.Content, { placement: "right", children: collapsed ? "Expand sidebar" : "Collapse sidebar" })] }))] })] }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-72 max-w-[80vw]", children: _jsxs(Drawer.Dialog, { "aria-label": "Navigation menu", children: [_jsx(Drawer.CloseTrigger, {}), _jsx(Drawer.Header, { children: _jsx(Drawer.Heading, { "aria-label": name, children: _jsx(ConnectLogo, { name: name, icon: icon }) }) }), _jsx(Drawer.Body, { children: sideNav(true) }), sidebarFooter && _jsx(Drawer.Footer, { className: "flex flex-col items-stretch gap-3", children: sidebarFooter })] }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-16 shrink-0 items-center gap-3 border-b border-border bg-surface px-4 md:px-6", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", className: "md:hidden", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), typeof title === "string" ? (_jsx("h1", { className: "truncate text-lg font-semibold text-foreground", children: title })) : (title), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [headerActions, showAppsGrid && _jsx(ConnectAppsGrid, {}), _jsx(ConnectUserMenu, { compact: true, theme: theme, onThemeChange: onThemeChange }), showAuthButtons && (_jsxs(_Fragment, { children: [_jsx(ConnectLoginButton, { size: "sm" }), _jsx(ConnectRegisterButton, { size: "sm" })] }))] })] }), _jsx("main", { className: "flex-1 overflow-y-auto", children: _jsxs("div", { className: contentClassName ?? "mx-auto w-full max-w-6xl px-4 py-6 md:px-6 md:py-8", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && _jsx(ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }));
|
|
154
155
|
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Button, Input, Label, TextField } from "@heroui/react";
|
|
5
|
+
import { Brain, ChevronDown, Send, Sparkles, Square, X } from "lucide-react";
|
|
6
|
+
import { Streamdown } from "streamdown";
|
|
7
|
+
import { useConnect } from "../provider.js";
|
|
8
|
+
import { AiChart, AiDetails, AiMetrics, AiSources, AiSteps, AiTable, AiTimeline } from "./ai-widgets.js";
|
|
9
|
+
const PANEL_WIDTH = "w-full lg:w-[400px]";
|
|
10
|
+
/**
|
|
11
|
+
* `blurIn` over the default `fadeIn`: Gemini arrives in bursts rather than an
|
|
12
|
+
* even token drip, and a blur masks a batch landing at once where a plain
|
|
13
|
+
* opacity fade just flickers. Hoisted to module scope because Streamdown
|
|
14
|
+
* compares `animated` by reference.
|
|
15
|
+
*/
|
|
16
|
+
const ANIMATED = {
|
|
17
|
+
animation: "blurIn",
|
|
18
|
+
duration: 220,
|
|
19
|
+
easing: "ease-out",
|
|
20
|
+
sep: "word",
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The "working on it" state - a highlight sweeping through the word rather
|
|
24
|
+
* than a spinner beside it. `connect-ai-shimmer` is defined in the SDK's
|
|
25
|
+
* `theme.css`, which every application platform already imports.
|
|
26
|
+
*/
|
|
27
|
+
function ThinkingShimmer({ label = "Thinking" }) {
|
|
28
|
+
return (_jsx("span", { className: "connect-ai-shimmer text-xs font-medium", role: "status", children: label }));
|
|
29
|
+
}
|
|
30
|
+
/** Open while it streams, collapsed once the answer lands. */
|
|
31
|
+
function ThinkingDisclosure({ text, isStreaming }) {
|
|
32
|
+
if (!text)
|
|
33
|
+
return null;
|
|
34
|
+
return (_jsxs("details", { className: "group mb-2", open: isStreaming, children: [_jsxs("summary", { className: "flex cursor-pointer list-none items-center gap-1 text-[11px] font-medium text-muted select-none hover:text-foreground", children: [_jsx(Brain, { className: "size-3" }), isStreaming ? _jsx(ThinkingShimmer, {}) : "Thinking", _jsx(ChevronDown, { className: "size-3 transition-transform group-open:rotate-180" })] }), _jsx("div", { className: "mt-1.5 border-s-2 border-border ps-2 text-xs whitespace-pre-wrap text-muted italic", children: text })] }));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Structural guards, one per widget.
|
|
38
|
+
*
|
|
39
|
+
* The published package carries no schema validator, and should not gain one
|
|
40
|
+
* for this - so each tool input is checked for the fields its renderer would
|
|
41
|
+
* otherwise crash on. Anything that fails renders nothing: the model's prose
|
|
42
|
+
* sits around it either way, so a missing widget reads as terse rather than
|
|
43
|
+
* broken, which is the better failure.
|
|
44
|
+
*/
|
|
45
|
+
const isArray = (value) => Array.isArray(value) && value.length > 0;
|
|
46
|
+
const field = (input, key) => typeof input === "object" && input !== null ? input[key] : undefined;
|
|
47
|
+
function renderTool(name, input, renderMarkdown) {
|
|
48
|
+
switch (name) {
|
|
49
|
+
case "showTable":
|
|
50
|
+
return isArray(field(input, "columns")) && Array.isArray(field(input, "rows")) ? (_jsx(AiTable, { ...input })) : null;
|
|
51
|
+
case "showChart":
|
|
52
|
+
return isArray(field(input, "series")) ? _jsx(AiChart, { ...input }) : null;
|
|
53
|
+
case "showMetrics":
|
|
54
|
+
return isArray(field(input, "metrics")) ? _jsx(AiMetrics, { ...input }) : null;
|
|
55
|
+
case "showTimeline":
|
|
56
|
+
return isArray(field(input, "events")) ? _jsx(AiTimeline, { ...input }) : null;
|
|
57
|
+
case "showDetails":
|
|
58
|
+
return isArray(field(input, "fields")) ? _jsx(AiDetails, { ...input }) : null;
|
|
59
|
+
case "showSteps":
|
|
60
|
+
return isArray(field(input, "steps")) ? (_jsx(AiSteps, { ...input, renderDetail: renderMarkdown })) : null;
|
|
61
|
+
case "showSources":
|
|
62
|
+
return isArray(field(input, "sources")) ? _jsx(AiSources, { ...input }) : null;
|
|
63
|
+
default:
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Placeholder while a tool's arguments are still arriving, sized like the real thing. */
|
|
68
|
+
function ToolSkeleton() {
|
|
69
|
+
return (_jsx("div", { className: "my-3 flex h-24 animate-pulse items-center justify-center rounded-lg border border-border bg-surface-secondary", role: "status", "aria-label": "Preparing result", children: _jsx(Sparkles, { className: "size-4 text-accent/60", "aria-hidden": true }) }));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* One server-sent event stream, folded into the turn being built.
|
|
73
|
+
*
|
|
74
|
+
* Hand-rolled rather than pulled from `@ai-sdk/react`, and that is a deliberate
|
|
75
|
+
* trade: this package ships to partners, and `ai` + `@ai-sdk/react` is a large
|
|
76
|
+
* dependency to add to every application platform for one panel. The wire format
|
|
77
|
+
* is small, both ends of it are ours, and the chunk kinds below are all this
|
|
78
|
+
* panel can render.
|
|
79
|
+
*
|
|
80
|
+
* Tool arguments are read from `tool-input-available` - the complete object -
|
|
81
|
+
* rather than reassembled from `tool-input-delta` fragments. `tool-input-start`
|
|
82
|
+
* is enough to put a skeleton on screen in the meantime, and partial-JSON
|
|
83
|
+
* parsing buys nothing a placeholder does not.
|
|
84
|
+
*
|
|
85
|
+
* Chunks are newline-delimited `data:` frames; a frame can split across network
|
|
86
|
+
* reads, so the tail of each read is carried forward rather than parsed.
|
|
87
|
+
*/
|
|
88
|
+
async function readStream(response, onChunk) {
|
|
89
|
+
const body = response.body;
|
|
90
|
+
if (!body)
|
|
91
|
+
throw new Error("Ask AI returned no stream");
|
|
92
|
+
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
|
|
93
|
+
let buffer = "";
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done)
|
|
97
|
+
break;
|
|
98
|
+
buffer += value;
|
|
99
|
+
// Everything up to the last newline is whole; whatever follows it is a
|
|
100
|
+
// partial frame and waits for the next read.
|
|
101
|
+
const lines = buffer.split("\n");
|
|
102
|
+
buffer = lines.pop() ?? "";
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
if (!line.startsWith("data:"))
|
|
105
|
+
continue;
|
|
106
|
+
const payload = line.slice(5).trim();
|
|
107
|
+
if (!payload || payload === "[DONE]")
|
|
108
|
+
continue;
|
|
109
|
+
let chunk;
|
|
110
|
+
try {
|
|
111
|
+
chunk = JSON.parse(payload);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// A frame we cannot parse is a frame we cannot render. Dropping it
|
|
115
|
+
// keeps the rest of the answer flowing, which is strictly better than
|
|
116
|
+
// failing the whole turn over one malformed chunk.
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
switch (chunk.type) {
|
|
120
|
+
case "text-delta":
|
|
121
|
+
if (chunk.delta)
|
|
122
|
+
onChunk({ text: chunk.delta });
|
|
123
|
+
break;
|
|
124
|
+
case "reasoning-delta":
|
|
125
|
+
if (chunk.delta)
|
|
126
|
+
onChunk({ reasoning: chunk.delta });
|
|
127
|
+
break;
|
|
128
|
+
case "tool-input-start":
|
|
129
|
+
if (chunk.toolCallId && chunk.toolName) {
|
|
130
|
+
onChunk({ toolStart: { id: chunk.toolCallId, name: chunk.toolName } });
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
case "tool-input-available":
|
|
134
|
+
if (chunk.toolCallId)
|
|
135
|
+
onChunk({ toolInput: { id: chunk.toolCallId, input: chunk.input } });
|
|
136
|
+
break;
|
|
137
|
+
case "error":
|
|
138
|
+
throw new Error(chunk.errorText || "Ask AI failed");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Index of the final text block, or `-1`. A reverse loop rather than
|
|
145
|
+
* `findLastIndex`, which is ES2023 - this package targets ES2022 so its
|
|
146
|
+
* published output stays usable on the runtimes its `engines` field claims.
|
|
147
|
+
*/
|
|
148
|
+
function lastTextBlockIndex(blocks) {
|
|
149
|
+
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
150
|
+
if (blocks[index]?.kind === "text")
|
|
151
|
+
return index;
|
|
152
|
+
}
|
|
153
|
+
return -1;
|
|
154
|
+
}
|
|
155
|
+
/** Appends a text delta to the open text block, or opens one after a widget. */
|
|
156
|
+
function appendText(blocks, text) {
|
|
157
|
+
const last = blocks.at(-1);
|
|
158
|
+
if (last?.kind === "text") {
|
|
159
|
+
return [...blocks.slice(0, -1), { kind: "text", text: last.text + text }];
|
|
160
|
+
}
|
|
161
|
+
return [...blocks, { kind: "text", text }];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Ask AI - a chat panel grounded in the signed-in workspace's Context Index,
|
|
165
|
+
* streamed through your own bridge.
|
|
166
|
+
*
|
|
167
|
+
* Answers are not only prose. Connect's model can call a small set of
|
|
168
|
+
* presentation tools - `showTable`, `showChart`, `showMetrics`, `showTimeline`,
|
|
169
|
+
* `showDetails`, `showSteps`, `showSources` - and this panel renders each one
|
|
170
|
+
* natively: a sortable table with a CSV download, a chart, a row of metric
|
|
171
|
+
* tiles. Values arrive raw with a format tag, so they are formatted here rather
|
|
172
|
+
* than by the model, which is what lets a column sort numerically.
|
|
173
|
+
*
|
|
174
|
+
* Nothing here holds a credential: it POSTs to `${basePath}/ai/chat` on your
|
|
175
|
+
* own origin, and `createConnectHandler` is what reaches Connect. The workspace
|
|
176
|
+
* is resolved from the caller's session there, never sent from the browser - so
|
|
177
|
+
* a user can only ever ask about the workspace they are signed into.
|
|
178
|
+
*
|
|
179
|
+
* ```tsx
|
|
180
|
+
* <AppShell name="Dispatch" nav={nav}>
|
|
181
|
+
* {children}
|
|
182
|
+
* </AppShell>
|
|
183
|
+
* ```
|
|
184
|
+
*
|
|
185
|
+
* `AppShell` mounts it by default. Mount it directly only when you are not
|
|
186
|
+
* using the shell: it renders a fixed corner trigger plus a panel that is an
|
|
187
|
+
* *in-flow flex sibling* at `lg` and up, so opening it pushes your content
|
|
188
|
+
* aside rather than covering it. That requires it to be the last child of a
|
|
189
|
+
* `flex` row owning the viewport height - which is exactly what `AppShell` is.
|
|
190
|
+
* Below `lg` it is a full-screen sheet with a backdrop instead; there is no
|
|
191
|
+
* room to push anything on a phone.
|
|
192
|
+
*
|
|
193
|
+
* **Host setup:** markdown rendering needs `streamdown` installed, one Tailwind
|
|
194
|
+
* `@source "…/node_modules/streamdown/dist/*.js"` line so its utility classes
|
|
195
|
+
* are generated, and `import "streamdown/styles.css"` for the streaming
|
|
196
|
+
* animation. The colour tokens Streamdown expects are already mapped in this
|
|
197
|
+
* SDK's `theme.css`.
|
|
198
|
+
*
|
|
199
|
+
* Answers require the workspace's Context Index to be turned on (a workspace
|
|
200
|
+
* admin toggle in Connect) and your API key to carry the `ai:chat` scope.
|
|
201
|
+
* Neither is something the browser can discover, so both surface as a message
|
|
202
|
+
* in the panel rather than as a hidden button.
|
|
203
|
+
*/
|
|
204
|
+
export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placeholder = "Ask a question...", hint = "Ask a question about your workspace - answers are grounded in what your team has indexed.", defaultOpen = false, markdownPlugins, }) {
|
|
205
|
+
const { session, isLoading, streamResource } = useConnect();
|
|
206
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
207
|
+
const [turns, setTurns] = useState([]);
|
|
208
|
+
const [input, setInput] = useState("");
|
|
209
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
210
|
+
const [error, setError] = useState(null);
|
|
211
|
+
const abortRef = useRef(null);
|
|
212
|
+
const bottomRef = useRef(null);
|
|
213
|
+
// Ids only have to be unique within one mounted conversation, so a counter is
|
|
214
|
+
// enough - and unlike `crypto.randomUUID()` it cannot differ between the
|
|
215
|
+
// server's render and the browser's.
|
|
216
|
+
const nextId = useRef(0);
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
219
|
+
}, [turns]);
|
|
220
|
+
// A conversation left mid-stream should stop costing tokens the moment the
|
|
221
|
+
// panel goes away.
|
|
222
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
223
|
+
const renderMarkdown = useCallback((text, isStreaming = false) => (_jsx(Streamdown, { className: "connect-ai-body", mode: isStreaming ? "streaming" : "static", isAnimating: isStreaming, animated: ANIMATED, caret: isStreaming ? "block" : undefined, plugins: markdownPlugins,
|
|
224
|
+
// Retrieved documents cite real URLs; an interstitial on every one of
|
|
225
|
+
// them turns a citation into a chore. The renderer still sanitises the
|
|
226
|
+
// markup itself, which is the part that matters.
|
|
227
|
+
linkSafety: LINK_SAFETY, children: text })), [markdownPlugins]);
|
|
228
|
+
const stop = useCallback(() => {
|
|
229
|
+
abortRef.current?.abort();
|
|
230
|
+
abortRef.current = null;
|
|
231
|
+
setIsBusy(false);
|
|
232
|
+
}, []);
|
|
233
|
+
const send = useCallback(async (text) => {
|
|
234
|
+
const controller = new AbortController();
|
|
235
|
+
abortRef.current = controller;
|
|
236
|
+
setError(null);
|
|
237
|
+
setIsBusy(true);
|
|
238
|
+
const userTurn = {
|
|
239
|
+
id: `u${nextId.current++}`,
|
|
240
|
+
role: "user",
|
|
241
|
+
blocks: [{ kind: "text", text }],
|
|
242
|
+
reasoning: "",
|
|
243
|
+
};
|
|
244
|
+
const answerId = `a${nextId.current++}`;
|
|
245
|
+
// The wire format Connect reads - part-based `UIMessage`s, not flat
|
|
246
|
+
// strings. Built from the turns *plus* this one rather than from state,
|
|
247
|
+
// which has not been committed yet at this point. Only text is sent back:
|
|
248
|
+
// a widget is a rendering of something the model already said.
|
|
249
|
+
const history = [...turns, userTurn].map((turn) => ({
|
|
250
|
+
id: turn.id,
|
|
251
|
+
role: turn.role,
|
|
252
|
+
parts: [
|
|
253
|
+
{
|
|
254
|
+
type: "text",
|
|
255
|
+
text: turn.blocks
|
|
256
|
+
.filter((block) => block.kind === "text")
|
|
257
|
+
.map((block) => block.text)
|
|
258
|
+
.join(""),
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
}));
|
|
262
|
+
setTurns((current) => [...current, userTurn]);
|
|
263
|
+
const patchAnswer = (patch) => setTurns((current) => current.map((turn) => (turn.id === answerId ? patch(turn) : turn)));
|
|
264
|
+
try {
|
|
265
|
+
// Relative to the provider's own `basePath` - `streamResource` prefixes
|
|
266
|
+
// it, the same way every other bridge call is addressed.
|
|
267
|
+
const response = await streamResource("/ai/chat", { messages: history }, { signal: controller.signal });
|
|
268
|
+
// The assistant's turn is appended only once the stream is live, so a
|
|
269
|
+
// failed request leaves an error under the question rather than an
|
|
270
|
+
// empty bubble that never fills.
|
|
271
|
+
setTurns((current) => [...current, { id: answerId, role: "assistant", blocks: [], reasoning: "" }]);
|
|
272
|
+
await readStream(response, (chunk) => {
|
|
273
|
+
if (chunk.text) {
|
|
274
|
+
patchAnswer((turn) => ({ ...turn, blocks: appendText(turn.blocks, chunk.text) }));
|
|
275
|
+
}
|
|
276
|
+
if (chunk.reasoning) {
|
|
277
|
+
patchAnswer((turn) => ({ ...turn, reasoning: turn.reasoning + chunk.reasoning }));
|
|
278
|
+
}
|
|
279
|
+
if (chunk.toolStart) {
|
|
280
|
+
const { id, name } = chunk.toolStart;
|
|
281
|
+
patchAnswer((turn) => ({ ...turn, blocks: [...turn.blocks, { kind: "tool", id, name, input: null }] }));
|
|
282
|
+
}
|
|
283
|
+
if (chunk.toolInput) {
|
|
284
|
+
const { id, input: toolInput } = chunk.toolInput;
|
|
285
|
+
patchAnswer((turn) => ({
|
|
286
|
+
...turn,
|
|
287
|
+
blocks: turn.blocks.map((block) => block.kind === "tool" && block.id === id ? { ...block, input: toolInput } : block),
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch (cause) {
|
|
293
|
+
// Pressing Stop aborts the fetch, which lands here - a decision, not a
|
|
294
|
+
// failure, and nothing to report.
|
|
295
|
+
if (!controller.signal.aborted) {
|
|
296
|
+
setError(cause instanceof Error ? cause.message : "Ask AI failed");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
if (abortRef.current === controller)
|
|
301
|
+
abortRef.current = null;
|
|
302
|
+
setIsBusy(false);
|
|
303
|
+
}
|
|
304
|
+
}, [streamResource, turns]);
|
|
305
|
+
const lastTurn = turns.at(-1);
|
|
306
|
+
const isAnswering = isBusy && lastTurn?.role === "assistant";
|
|
307
|
+
const rendered = useMemo(() => turns.map((turn) => {
|
|
308
|
+
const isLive = isAnswering && turn.id === lastTurn?.id;
|
|
309
|
+
const lastTextIndex = lastTextBlockIndex(turn.blocks);
|
|
310
|
+
return (_jsxs("div", { className: `connect-ai-host rounded-md border border-border p-2.5 text-sm ${turn.role === "user" ? "bg-surface-secondary" : "bg-accent/5"}`, children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: turn.role === "user" ? "You" : assistantName }), turn.role === "assistant" && (_jsx(ThinkingDisclosure, { text: turn.reasoning, isStreaming: isLive && lastTextIndex === -1 })), turn.blocks.map((block, index) => block.kind === "text" ? (
|
|
311
|
+
// The user's own turn is shown as they typed it - running a
|
|
312
|
+
// question that happens to start with `#` through the markdown
|
|
313
|
+
// renderer would turn it into a heading.
|
|
314
|
+
_jsx("div", { children: turn.role === "user" ? (_jsx("p", { className: "whitespace-pre-wrap", children: block.text })) : (renderMarkdown(block.text, isLive && index === lastTextIndex)) }, index)) : block.input === null ? (_jsx(ToolSkeleton, {}, block.id)) : (_jsx("div", { children: renderTool(block.name, block.input, renderMarkdown) }, block.id))), isLive && lastTextIndex === -1 && turn.blocks.length === 0 && _jsx(ThinkingShimmer, {})] }, turn.id));
|
|
315
|
+
}), [turns, isAnswering, lastTurn?.id, assistantName, renderMarkdown]);
|
|
316
|
+
// Signed out there is no workspace to ground an answer in, and the bridge
|
|
317
|
+
// would answer 401 - so there is nothing to offer, not even a button.
|
|
318
|
+
if (isLoading || !session?.user)
|
|
319
|
+
return null;
|
|
320
|
+
const handleSubmit = (event) => {
|
|
321
|
+
event.preventDefault();
|
|
322
|
+
const text = input.trim();
|
|
323
|
+
if (!text || isBusy)
|
|
324
|
+
return;
|
|
325
|
+
setInput("");
|
|
326
|
+
void send(text);
|
|
327
|
+
};
|
|
328
|
+
return (_jsxs(_Fragment, { children: [!isOpen && (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: _jsx(Sparkles, { className: "size-5" }) })), _jsx("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), _jsx("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${PANEL_WIDTH} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width] ${isOpen ? "" : "lg:w-0 lg:overflow-hidden lg:border-s-0"}`, children: _jsxs("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [_jsx(Sparkles, { className: "size-4 shrink-0 text-accent" }), _jsx("span", { className: "flex-1 text-sm font-semibold", children: title }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: _jsx(X, { className: "size-4" }) })] }), _jsxs("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && _jsx("p", { className: "text-sm text-muted", children: hint }), rendered, isBusy && lastTurn?.role === "user" && (_jsxs("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), _jsx(ThinkingShimmer, {})] })), error && _jsx("p", { className: "text-xs text-danger", children: error }), _jsx("div", { ref: bottomRef })] }), _jsxs("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [_jsxs(TextField, { variant: "secondary", className: "flex-1", children: [_jsx(Label, { className: "sr-only", children: placeholder }), _jsx(Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? (_jsx(Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: _jsx(Square, { className: "size-3.5" }) })) : (_jsx(Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: _jsx(Send, { className: "size-3.5" }) }))] })] }) })] }));
|
|
329
|
+
}
|
|
330
|
+
/** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
|
|
331
|
+
const LINK_SAFETY = { enabled: false };
|
|
@@ -81,7 +81,7 @@ export function isNavItemActive(item, activeHref) {
|
|
|
81
81
|
return false;
|
|
82
82
|
return normalizePath(item.href) === activeHref;
|
|
83
83
|
}
|
|
84
|
-
/** Hrefs declared `gateExempt` - the routes `
|
|
84
|
+
/** Hrefs declared `gateExempt` - the routes `AppShell` renders outside its `ConnectAccessGate`. */
|
|
85
85
|
export function gateExemptPaths(nav) {
|
|
86
86
|
return flattenNav(nav)
|
|
87
87
|
.filter((item) => item.gateExempt)
|