@btst/stack 4.1.1 → 4.3.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 +2 -2
- package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +15 -9
- package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +16 -10
- package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +28 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +29 -2
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +5 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +5 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +91 -8
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +91 -8
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +242 -68
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +246 -72
- package/dist/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.cjs +17 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.mjs +18 -1
- package/dist/packages/stack/src/plugins/ai-chat/client/context/chat-analytics.cjs +42 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/context/chat-analytics.mjs +39 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/plugin.cjs +2 -1
- package/dist/packages/stack/src/plugins/ai-chat/client/plugin.mjs +2 -1
- package/dist/plugins/ai-chat/api/index.d.cts +2 -2
- package/dist/plugins/ai-chat/api/index.d.mts +2 -2
- package/dist/plugins/ai-chat/api/index.d.ts +2 -2
- package/dist/plugins/ai-chat/client/components/index.d.cts +2 -1
- package/dist/plugins/ai-chat/client/components/index.d.mts +2 -1
- package/dist/plugins/ai-chat/client/components/index.d.ts +2 -1
- package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +4 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +6 -2
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +6 -2
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +6 -2
- package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +4 -1
- package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
- package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
- package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
- package/dist/plugins/ai-chat/client/index.d.cts +7 -3
- package/dist/plugins/ai-chat/client/index.d.mts +7 -3
- package/dist/plugins/ai-chat/client/index.d.ts +7 -3
- package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
- package/dist/plugins/blog/api/index.d.cts +2 -2
- package/dist/plugins/blog/api/index.d.mts +2 -2
- package/dist/plugins/blog/api/index.d.ts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +2 -2
- package/dist/plugins/blog/client/index.d.mts +2 -2
- package/dist/plugins/blog/client/index.d.ts +2 -2
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/form-builder/api/index.d.cts +1 -1
- package/dist/plugins/form-builder/api/index.d.mts +1 -1
- package/dist/plugins/form-builder/api/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/form-builder/query-keys.d.cts +1 -1
- package/dist/plugins/form-builder/query-keys.d.mts +1 -1
- package/dist/plugins/form-builder/query-keys.d.ts +1 -1
- package/dist/shared/{stack.B5yPgHsp.d.mts → stack.B7Mj74BQ.d.mts} +18 -2
- package/dist/shared/{stack.rVF0f509.d.ts → stack.CHrtGKLz.d.mts} +66 -4
- package/dist/shared/{stack.BggTHKlo.d.mts → stack.CZ1h_T4k.d.mts} +90 -90
- package/dist/shared/{stack.DfrRYOIl.d.ts → stack.CnDMBj4z.d.ts} +5 -5
- package/dist/shared/{stack.94kN3pEK.d.mts → stack.D2y4gl03.d.mts} +5 -5
- package/dist/shared/{stack.3Sr6qRtT.d.ts → stack.DBH1Bjko.d.ts} +18 -2
- package/dist/shared/{stack.lPKwd_v0.d.ts → stack.DQpyA3Cq.d.ts} +90 -90
- package/dist/shared/{stack.C4oVW2qK.d.cts → stack.Dgt3MCtW.d.cts} +18 -2
- package/dist/shared/{stack.CtrnoQgH.d.cts → stack.DiYg7Jw1.d.cts} +90 -90
- package/dist/shared/{stack.rVF0f509.d.cts → stack.DzJgoNqJ.d.cts} +66 -4
- package/dist/shared/{stack.rVF0f509.d.mts → stack.qMp2VzNw.d.ts} +66 -4
- package/dist/shared/{stack.BTStHVTy.d.cts → stack.rmWRtSjs.d.cts} +5 -5
- package/package.json +2 -2
- package/src/plugins/ai-chat/__tests__/authorization.test.ts +106 -1
- package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +26 -3
- package/src/plugins/ai-chat/__tests__/page-content.test.ts +177 -0
- package/src/plugins/ai-chat/__tests__/page-widget.test.tsx +675 -0
- package/src/plugins/ai-chat/api/operations.ts +29 -13
- package/src/plugins/ai-chat/api/page-tools.ts +56 -2
- package/src/plugins/ai-chat/api/plugin.ts +13 -0
- package/src/plugins/ai-chat/client/analytics.ts +35 -0
- package/src/plugins/ai-chat/client/components/chat-interface.tsx +113 -10
- package/src/plugins/ai-chat/client/components/chat-layout.tsx +304 -68
- package/src/plugins/ai-chat/client/components/tool-call-display.tsx +29 -1
- package/src/plugins/ai-chat/client/context/chat-analytics.tsx +46 -0
- package/src/plugins/ai-chat/client/context/page-ai-context.tsx +9 -1
- package/src/plugins/ai-chat/client/index.ts +2 -0
- package/src/plugins/ai-chat/client/localization/index.ts +12 -0
- package/src/plugins/ai-chat/client/overrides.ts +5 -0
- package/src/plugins/ai-chat/client/plugin.tsx +3 -0
- package/dist/shared/{stack.BuxUQqL8.d.ts → stack.CGmh-Lpe.d.ts} +12 -12
- package/dist/shared/{stack.Egr3RWhA.d.mts → stack.Doy2_wgl.d.mts} +12 -12
- package/dist/shared/{stack.CKc_Bais.d.cts → stack.DtZgEX84.d.cts} +6 -6
- package/dist/shared/{stack.CKc_Bais.d.mts → stack.DtZgEX84.d.mts} +6 -6
- package/dist/shared/{stack.CKc_Bais.d.ts → stack.DtZgEX84.d.ts} +6 -6
- package/dist/shared/{stack.CX83YNuS.d.cts → stack.zch70Loj.d.cts} +12 -12
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
12
|
+
import {
|
|
13
|
+
Dialog,
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
} from "@workspace/ui/components/dialog";
|
|
4
17
|
import { Button } from "@workspace/ui/components/button";
|
|
5
18
|
import { Badge } from "@workspace/ui/components/badge";
|
|
6
19
|
import {
|
|
@@ -13,6 +26,8 @@ import {
|
|
|
13
26
|
PanelLeftClose,
|
|
14
27
|
PanelLeft,
|
|
15
28
|
Sparkles,
|
|
29
|
+
Maximize2,
|
|
30
|
+
Minimize2,
|
|
16
31
|
Trash2,
|
|
17
32
|
X,
|
|
18
33
|
} from "lucide-react";
|
|
@@ -20,10 +35,18 @@ import { cn } from "@workspace/ui/lib/utils";
|
|
|
20
35
|
import { ChatSidebar } from "./chat-sidebar";
|
|
21
36
|
import { ChatInterface } from "./chat-interface";
|
|
22
37
|
import type { UIMessage } from "ai";
|
|
23
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
usePageAIContext,
|
|
40
|
+
PageAIContextScope,
|
|
41
|
+
type PageAIContextConfig,
|
|
42
|
+
} from "../context/page-ai-context";
|
|
24
43
|
import { usePluginOverrides, useStack } from "@btst/stack/context";
|
|
25
44
|
import type { AiChatPluginOverrides } from "../overrides";
|
|
26
45
|
import { resolveAiChatMode } from "../overrides";
|
|
46
|
+
import {
|
|
47
|
+
ChatAnalyticsPageKey,
|
|
48
|
+
useChatAnalytics,
|
|
49
|
+
} from "../context/chat-analytics";
|
|
27
50
|
import { useAiChatTranslation } from "../localization";
|
|
28
51
|
|
|
29
52
|
interface ChatLayoutBaseProps {
|
|
@@ -31,6 +54,15 @@ interface ChatLayoutBaseProps {
|
|
|
31
54
|
conversationId?: string;
|
|
32
55
|
/** Additional class name for the container */
|
|
33
56
|
className?: string;
|
|
57
|
+
/** Additional container styles, including application-specific floating offsets. */
|
|
58
|
+
style?: CSSProperties;
|
|
59
|
+
/** Instance-local context. Omit to inherit page registrations; null disables them. */
|
|
60
|
+
pageContext?: PageAIContextConfig | null;
|
|
61
|
+
/**
|
|
62
|
+
* Page identity. Changing it resets the panel, tip, and conversation, stopping
|
|
63
|
+
* an active stream. Supply initialMessages/conversationId for this page only.
|
|
64
|
+
*/
|
|
65
|
+
pageKey?: string;
|
|
34
66
|
/** Whether to show the sidebar */
|
|
35
67
|
showSidebar?: boolean;
|
|
36
68
|
/** Initial messages to populate the chat (useful for localStorage persistence in public mode) */
|
|
@@ -44,10 +76,18 @@ interface ChatLayoutBaseProps {
|
|
|
44
76
|
interface ChatLayoutWidgetProps extends ChatLayoutBaseProps {
|
|
45
77
|
/** Widget mode: compact embeddable panel with a floating trigger button */
|
|
46
78
|
layout: "widget";
|
|
47
|
-
/** Height of the widget panel. Default: `"
|
|
79
|
+
/** Height of the widget panel. Default: `"min(640px, calc(100dvh - 112px))"` */
|
|
48
80
|
widgetHeight?: string | number;
|
|
49
|
-
/** Width of the widget panel. Default: `"
|
|
81
|
+
/** Width of the widget panel. Default: `"min(440px, calc(100vw - 32px))"` */
|
|
50
82
|
widgetWidth?: string | number;
|
|
83
|
+
/** Show a button to expand the widget to the viewport. Default: false. Escape restores the compact size. */
|
|
84
|
+
expandable?: boolean;
|
|
85
|
+
/** Fix the widget to the bottom right. Default: false (embedded). Override offsets with className or style. */
|
|
86
|
+
floating?: boolean;
|
|
87
|
+
/** Optional dismissible introduction shown while the widget is closed. */
|
|
88
|
+
introTip?: string;
|
|
89
|
+
/** Milliseconds before hiding the introduction. Default: 6000. */
|
|
90
|
+
introTipDuration?: number;
|
|
51
91
|
/**
|
|
52
92
|
* Whether the widget panel starts open. Default: `false`.
|
|
53
93
|
* Set to `true` when embedding inside an already-open container such as a
|
|
@@ -62,6 +102,8 @@ interface ChatLayoutWidgetProps extends ChatLayoutBaseProps {
|
|
|
62
102
|
* so that the built-in button does not appear alongside your own UI.
|
|
63
103
|
*/
|
|
64
104
|
showTrigger?: boolean;
|
|
105
|
+
/** Icon shown in the closed widget's trigger button. Defaults to Sparkles. */
|
|
106
|
+
triggerIcon?: ReactNode;
|
|
65
107
|
}
|
|
66
108
|
|
|
67
109
|
interface ChatLayoutFullProps extends ChatLayoutBaseProps {
|
|
@@ -77,10 +119,22 @@ export type ChatLayoutProps = ChatLayoutWidgetProps | ChatLayoutFullProps;
|
|
|
77
119
|
* or a compact widget mode for embedding.
|
|
78
120
|
*/
|
|
79
121
|
export function ChatLayout(props: ChatLayoutProps) {
|
|
122
|
+
return (
|
|
123
|
+
<PageAIContextScope.Provider value={props.pageContext}>
|
|
124
|
+
<ChatAnalyticsPageKey.Provider value={props.pageKey}>
|
|
125
|
+
<ChatLayoutContent key={props.pageKey} {...props} />
|
|
126
|
+
</ChatAnalyticsPageKey.Provider>
|
|
127
|
+
</PageAIContextScope.Provider>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ChatLayoutContent(props: ChatLayoutProps) {
|
|
132
|
+
const track = useChatAnalytics();
|
|
80
133
|
const {
|
|
81
134
|
conversationId,
|
|
82
135
|
layout = "full",
|
|
83
136
|
className,
|
|
137
|
+
style,
|
|
84
138
|
showSidebar: requestedShowSidebar = true,
|
|
85
139
|
initialMessages,
|
|
86
140
|
onMessagesChange,
|
|
@@ -97,13 +151,18 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
97
151
|
|
|
98
152
|
// Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here
|
|
99
153
|
const widgetHeight =
|
|
100
|
-
props.layout === "widget"
|
|
154
|
+
props.layout === "widget"
|
|
155
|
+
? (props.widgetHeight ?? "min(640px, calc(100dvh - 112px))")
|
|
156
|
+
: undefined;
|
|
101
157
|
const widgetWidth =
|
|
102
|
-
props.layout === "widget"
|
|
158
|
+
props.layout === "widget"
|
|
159
|
+
? (props.widgetWidth ?? "min(440px, calc(100vw - 32px))")
|
|
160
|
+
: undefined;
|
|
103
161
|
const defaultOpen =
|
|
104
162
|
props.layout === "widget" ? (props.defaultOpen ?? false) : false;
|
|
105
163
|
const showTrigger =
|
|
106
164
|
props.layout === "widget" ? (props.showTrigger ?? true) : true;
|
|
165
|
+
const triggerIcon = props.layout === "widget" ? props.triggerIcon : undefined;
|
|
107
166
|
|
|
108
167
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
109
168
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
@@ -111,6 +170,40 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
111
170
|
const [chatResetKey, setChatResetKey] = useState(0);
|
|
112
171
|
// Widget open/closed state — starts with defaultOpen value
|
|
113
172
|
const [widgetOpen, setWidgetOpen] = useState(defaultOpen);
|
|
173
|
+
const [expanded, setExpanded] = useState(false);
|
|
174
|
+
const expandable = props.layout === "widget" && props.expandable === true;
|
|
175
|
+
const widgetExpanded = expandable && expanded && widgetOpen;
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!expandable) setExpanded(false);
|
|
178
|
+
}, [expandable]);
|
|
179
|
+
const compactPanelRef = useRef<HTMLDivElement>(null);
|
|
180
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
181
|
+
const expandRef = useRef<HTMLButtonElement>(null);
|
|
182
|
+
const [panelContainer, setPanelContainer] = useState<HTMLDivElement | null>(
|
|
183
|
+
null,
|
|
184
|
+
);
|
|
185
|
+
const expandLabel = widgetExpanded
|
|
186
|
+
? tr("A11Y_COLLAPSE_CHAT", "aiChat.a11y.collapseChat", "Collapse chat")
|
|
187
|
+
: tr("A11Y_EXPAND_CHAT", "aiChat.a11y.expandChat", "Expand chat");
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (layout !== "widget") return;
|
|
191
|
+
const container = document.createElement("div");
|
|
192
|
+
container.className = "flex h-full min-h-0 flex-col";
|
|
193
|
+
compactPanelRef.current?.appendChild(container);
|
|
194
|
+
setPanelContainer(container);
|
|
195
|
+
return () => container.remove();
|
|
196
|
+
}, [layout]);
|
|
197
|
+
// The portal target stays the same; only its DOM parent changes. This keeps
|
|
198
|
+
// the draft, attachments and active stream mounted while Dialog owns modality.
|
|
199
|
+
const mountExpandedPanel = useCallback(
|
|
200
|
+
(node: HTMLDivElement | null) => {
|
|
201
|
+
if (panelContainer) {
|
|
202
|
+
(node ?? compactPanelRef.current)?.appendChild(panelContainer);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
[panelContainer],
|
|
206
|
+
);
|
|
114
207
|
// Key to force widget ChatInterface remount on clear
|
|
115
208
|
const [widgetResetKey, setWidgetResetKey] = useState(0);
|
|
116
209
|
// Only mount the widget ChatInterface after the widget has been opened at least once.
|
|
@@ -118,6 +211,38 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
118
211
|
// so suggestion chips and tool hints appear immediately on first open.
|
|
119
212
|
// When defaultOpen is true the widget is pre-opened, so we mark it as ever-opened immediately.
|
|
120
213
|
const [widgetEverOpened, setWidgetEverOpened] = useState(defaultOpen);
|
|
214
|
+
const [tipDismissed, setTipDismissed] = useState(defaultOpen);
|
|
215
|
+
const introTip = props.layout === "widget" ? props.introTip : undefined;
|
|
216
|
+
const introTipDuration =
|
|
217
|
+
props.layout === "widget" ? (props.introTipDuration ?? 6000) : 6000;
|
|
218
|
+
const tipShown = useRef(false);
|
|
219
|
+
const tipDismissedRef = useRef(defaultOpen);
|
|
220
|
+
const dismissTip = useCallback(
|
|
221
|
+
(reason: "timeout" | "manual" | "open") => {
|
|
222
|
+
if (tipShown.current && !tipDismissedRef.current) {
|
|
223
|
+
tipDismissedRef.current = true;
|
|
224
|
+
track({ type: "intro_tip_dismissed", reason });
|
|
225
|
+
}
|
|
226
|
+
setTipDismissed(true);
|
|
227
|
+
},
|
|
228
|
+
[track],
|
|
229
|
+
);
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!introTip || !showTrigger || tipDismissed) return;
|
|
232
|
+
if (!tipShown.current) {
|
|
233
|
+
tipShown.current = true;
|
|
234
|
+
track({ type: "intro_tip_shown" });
|
|
235
|
+
}
|
|
236
|
+
const timeout = setTimeout(() => dismissTip("timeout"), introTipDuration);
|
|
237
|
+
return () => clearTimeout(timeout);
|
|
238
|
+
}, [
|
|
239
|
+
introTip,
|
|
240
|
+
introTipDuration,
|
|
241
|
+
showTrigger,
|
|
242
|
+
tipDismissed,
|
|
243
|
+
dismissTip,
|
|
244
|
+
track,
|
|
245
|
+
]);
|
|
121
246
|
|
|
122
247
|
// Read page AI context to show badge in header
|
|
123
248
|
const pageAIContext = usePageAIContext();
|
|
@@ -136,88 +261,197 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
136
261
|
<div
|
|
137
262
|
className={cn(
|
|
138
263
|
"flex flex-col items-end gap-3",
|
|
264
|
+
props.layout === "widget" &&
|
|
265
|
+
props.floating &&
|
|
266
|
+
"fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-50",
|
|
139
267
|
className,
|
|
140
268
|
classNames?.container,
|
|
141
269
|
)}
|
|
270
|
+
style={style}
|
|
142
271
|
>
|
|
143
|
-
{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"flex
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
style={{ height: widgetHeight, width: widgetWidth }}
|
|
150
|
-
>
|
|
151
|
-
{/* Widget header with page context badge and action buttons */}
|
|
152
|
-
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b bg-muted/40">
|
|
153
|
-
<Sparkles className="h-3 w-3 text-muted-foreground" />
|
|
154
|
-
{pageAIContext ? (
|
|
155
|
-
<Badge
|
|
156
|
-
variant="secondary"
|
|
157
|
-
className="text-xs"
|
|
158
|
-
data-testid="page-context-badge"
|
|
159
|
-
>
|
|
160
|
-
{pageAIContext.routeName}
|
|
161
|
-
</Badge>
|
|
162
|
-
) : (
|
|
163
|
-
<span className="text-xs text-muted-foreground font-medium">
|
|
164
|
-
{tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
|
|
165
|
-
</span>
|
|
166
|
-
)}
|
|
167
|
-
<div className="flex-1" />
|
|
168
|
-
<Button
|
|
169
|
-
variant="ghost"
|
|
170
|
-
size="icon"
|
|
171
|
-
className="h-5 w-5"
|
|
172
|
-
onClick={() => {
|
|
173
|
-
onClear?.();
|
|
174
|
-
setWidgetResetKey((prev) => prev + 1);
|
|
175
|
-
}}
|
|
176
|
-
aria-label={tr(
|
|
177
|
-
"A11Y_CLEAR_CHAT",
|
|
178
|
-
"aiChat.a11y.clearChat",
|
|
179
|
-
"Clear chat",
|
|
180
|
-
)}
|
|
181
|
-
title={tr(
|
|
182
|
-
"A11Y_CLEAR_CHAT",
|
|
183
|
-
"aiChat.a11y.clearChat",
|
|
184
|
-
"Clear chat",
|
|
185
|
-
)}
|
|
186
|
-
>
|
|
187
|
-
<Trash2 className="h-3.5 w-3.5" />
|
|
188
|
-
</Button>
|
|
272
|
+
{introTip && showTrigger && !tipDismissed && !widgetOpen && (
|
|
273
|
+
<div
|
|
274
|
+
role="status"
|
|
275
|
+
className="flex max-w-[min(280px,calc(100vw-32px))] items-center gap-2 rounded-lg border bg-background p-3 text-sm text-foreground shadow-lg"
|
|
276
|
+
>
|
|
277
|
+
<span>{introTip}</span>
|
|
189
278
|
<Button
|
|
279
|
+
type="button"
|
|
190
280
|
variant="ghost"
|
|
191
281
|
size="icon"
|
|
192
|
-
className="h-
|
|
193
|
-
onClick={() =>
|
|
282
|
+
className="h-7 w-7 shrink-0"
|
|
283
|
+
onClick={() => dismissTip("manual")}
|
|
194
284
|
aria-label={tr(
|
|
195
|
-
"
|
|
196
|
-
"aiChat.a11y.
|
|
197
|
-
"
|
|
285
|
+
"A11Y_DISMISS_CHAT_TIP",
|
|
286
|
+
"aiChat.a11y.dismissChatTip",
|
|
287
|
+
"Dismiss chat tip",
|
|
198
288
|
)}
|
|
199
289
|
>
|
|
200
|
-
<X className="h-
|
|
290
|
+
<X className="h-4 w-4" aria-hidden="true" />
|
|
201
291
|
</Button>
|
|
202
292
|
</div>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
293
|
+
)}
|
|
294
|
+
{/* Compact panel remains available when the fullscreen dialog closes. */}
|
|
295
|
+
<div
|
|
296
|
+
ref={compactPanelRef}
|
|
297
|
+
role="dialog"
|
|
298
|
+
tabIndex={-1}
|
|
299
|
+
aria-label={tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
|
|
300
|
+
className={cn(
|
|
301
|
+
"overflow-hidden rounded-xl border bg-background shadow-xl",
|
|
302
|
+
(!widgetOpen || widgetExpanded) && "hidden",
|
|
303
|
+
)}
|
|
304
|
+
style={{ height: widgetHeight, width: widgetWidth }}
|
|
305
|
+
/>
|
|
306
|
+
<Dialog open={widgetExpanded} onOpenChange={setExpanded}>
|
|
307
|
+
<DialogContent
|
|
308
|
+
showCloseButton={false}
|
|
309
|
+
style={{
|
|
310
|
+
...style,
|
|
311
|
+
position: "fixed",
|
|
312
|
+
inset: 0,
|
|
313
|
+
top: 0,
|
|
314
|
+
right: 0,
|
|
315
|
+
bottom: 0,
|
|
316
|
+
left: 0,
|
|
317
|
+
width: "100vw",
|
|
318
|
+
height: "100dvh",
|
|
319
|
+
minWidth: 0,
|
|
320
|
+
minHeight: 0,
|
|
321
|
+
maxWidth: "none",
|
|
322
|
+
maxHeight: "none",
|
|
323
|
+
margin: 0,
|
|
324
|
+
transform: "none",
|
|
325
|
+
translate: "none",
|
|
326
|
+
zIndex: 50,
|
|
327
|
+
animation: "none",
|
|
328
|
+
}}
|
|
329
|
+
aria-describedby={undefined}
|
|
330
|
+
className={cn(
|
|
331
|
+
className,
|
|
332
|
+
classNames?.container,
|
|
333
|
+
"fixed inset-0 z-50 flex h-dvh w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:max-w-none pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]",
|
|
334
|
+
)}
|
|
335
|
+
onCloseAutoFocus={(event) => {
|
|
336
|
+
const target = widgetOpen
|
|
337
|
+
? (expandRef.current ?? compactPanelRef.current)
|
|
338
|
+
: triggerRef.current;
|
|
339
|
+
if (target) {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
target.focus();
|
|
342
|
+
}
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
<DialogTitle className="sr-only">
|
|
346
|
+
{tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
|
|
347
|
+
</DialogTitle>
|
|
348
|
+
<div ref={mountExpandedPanel} className="min-h-0 flex-1" />
|
|
349
|
+
</DialogContent>
|
|
350
|
+
</Dialog>
|
|
351
|
+
{panelContainer &&
|
|
352
|
+
createPortal(
|
|
353
|
+
<>
|
|
354
|
+
{/* Widget header with page context badge and action buttons */}
|
|
355
|
+
<div className="flex shrink-0 items-center gap-1.5 px-3 py-1.5 border-b bg-muted/40">
|
|
356
|
+
<Sparkles className="h-3 w-3 text-muted-foreground" />
|
|
357
|
+
{pageAIContext ? (
|
|
358
|
+
<Badge
|
|
359
|
+
variant="secondary"
|
|
360
|
+
className="text-xs"
|
|
361
|
+
data-testid="page-context-badge"
|
|
362
|
+
>
|
|
363
|
+
{pageAIContext.routeName}
|
|
364
|
+
</Badge>
|
|
365
|
+
) : (
|
|
366
|
+
<span className="text-xs text-muted-foreground font-medium">
|
|
367
|
+
{tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
|
|
368
|
+
</span>
|
|
369
|
+
)}
|
|
370
|
+
<div className="flex-1" />
|
|
371
|
+
{expandable && (
|
|
372
|
+
<Button
|
|
373
|
+
type="button"
|
|
374
|
+
variant="ghost"
|
|
375
|
+
size="icon"
|
|
376
|
+
className="h-7 w-7"
|
|
377
|
+
ref={expandRef}
|
|
378
|
+
onClick={() => setExpanded((prev) => !prev)}
|
|
379
|
+
aria-label={expandLabel}
|
|
380
|
+
title={expandLabel}
|
|
381
|
+
aria-expanded={widgetExpanded}
|
|
382
|
+
>
|
|
383
|
+
{widgetExpanded ? (
|
|
384
|
+
<Minimize2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
385
|
+
) : (
|
|
386
|
+
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
387
|
+
)}
|
|
388
|
+
</Button>
|
|
389
|
+
)}
|
|
390
|
+
<Button
|
|
391
|
+
variant="ghost"
|
|
392
|
+
size="icon"
|
|
393
|
+
className="h-5 w-5"
|
|
394
|
+
onClick={() => {
|
|
395
|
+
track({ type: "conversation_cleared" });
|
|
396
|
+
onClear?.();
|
|
397
|
+
setWidgetResetKey((prev) => prev + 1);
|
|
398
|
+
}}
|
|
399
|
+
aria-label={tr(
|
|
400
|
+
"A11Y_CLEAR_CHAT",
|
|
401
|
+
"aiChat.a11y.clearChat",
|
|
402
|
+
"Clear chat",
|
|
403
|
+
)}
|
|
404
|
+
title={tr(
|
|
405
|
+
"A11Y_CLEAR_CHAT",
|
|
406
|
+
"aiChat.a11y.clearChat",
|
|
407
|
+
"Clear chat",
|
|
408
|
+
)}
|
|
409
|
+
>
|
|
410
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
411
|
+
</Button>
|
|
412
|
+
<Button
|
|
413
|
+
variant="ghost"
|
|
414
|
+
size="icon"
|
|
415
|
+
className="h-5 w-5"
|
|
416
|
+
onClick={() => {
|
|
417
|
+
setWidgetOpen(false);
|
|
418
|
+
setExpanded(false);
|
|
419
|
+
triggerRef.current?.focus();
|
|
420
|
+
track({ type: "widget_closed" });
|
|
421
|
+
}}
|
|
422
|
+
aria-label={tr(
|
|
423
|
+
"A11Y_CLOSE_CHAT",
|
|
424
|
+
"aiChat.a11y.closeChat",
|
|
425
|
+
"Close chat",
|
|
426
|
+
)}
|
|
427
|
+
>
|
|
428
|
+
<X className="h-3.5 w-3.5" />
|
|
429
|
+
</Button>
|
|
430
|
+
</div>
|
|
431
|
+
{widgetEverOpened && (
|
|
432
|
+
<ChatInterface
|
|
433
|
+
key={`widget-${conversationId ?? "new"}-${widgetResetKey}`}
|
|
434
|
+
id={conversationId}
|
|
435
|
+
variant="widget"
|
|
436
|
+
initialMessages={initialMessages}
|
|
437
|
+
onMessagesChange={onMessagesChange}
|
|
438
|
+
/>
|
|
439
|
+
)}
|
|
440
|
+
</>,
|
|
441
|
+
panelContainer,
|
|
211
442
|
)}
|
|
212
|
-
</div>
|
|
213
443
|
|
|
214
444
|
{/* Trigger button — rendered only when showTrigger is true */}
|
|
215
445
|
{showTrigger && (
|
|
216
446
|
<Button
|
|
447
|
+
ref={triggerRef}
|
|
217
448
|
size="icon"
|
|
218
449
|
className="h-12 w-12 rounded-full shadow-lg"
|
|
219
450
|
onClick={() => {
|
|
451
|
+
dismissTip("open");
|
|
452
|
+
track({ type: widgetOpen ? "widget_closed" : "widget_opened" });
|
|
220
453
|
setWidgetOpen((prev) => !prev);
|
|
454
|
+
setExpanded(false);
|
|
221
455
|
setWidgetEverOpened(true);
|
|
222
456
|
}}
|
|
223
457
|
aria-label={
|
|
@@ -230,7 +464,7 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
230
464
|
{widgetOpen ? (
|
|
231
465
|
<X className="h-5 w-5" />
|
|
232
466
|
) : (
|
|
233
|
-
<Sparkles className="h-5 w-5" />
|
|
467
|
+
(triggerIcon ?? <Sparkles className="h-5 w-5" />)
|
|
234
468
|
)}
|
|
235
469
|
</Button>
|
|
236
470
|
)}
|
|
@@ -247,6 +481,7 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
247
481
|
classNames?.container,
|
|
248
482
|
)}
|
|
249
483
|
data-testid="chat-layout"
|
|
484
|
+
style={style}
|
|
250
485
|
>
|
|
251
486
|
{/* Desktop Sidebar */}
|
|
252
487
|
{showSidebar && (
|
|
@@ -347,6 +582,7 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
347
582
|
variant="ghost"
|
|
348
583
|
size="icon"
|
|
349
584
|
onClick={() => {
|
|
585
|
+
track({ type: "conversation_cleared" });
|
|
350
586
|
onClear();
|
|
351
587
|
setChatResetKey((prev) => prev + 1);
|
|
352
588
|
}}
|
|
@@ -10,7 +10,7 @@ import { MarkdownContent } from "@workspace/ui/components/markdown-content";
|
|
|
10
10
|
import { Skeleton } from "@workspace/ui/components/skeleton";
|
|
11
11
|
import { cn } from "@workspace/ui/lib/utils";
|
|
12
12
|
import { Wrench, Check, AlertCircle, Loader2 } from "lucide-react";
|
|
13
|
-
import { usePluginOverrides } from "@btst/stack/context";
|
|
13
|
+
import { usePluginOverrides, useStack } from "@btst/stack/context";
|
|
14
14
|
import type {
|
|
15
15
|
AiChatPluginOverrides,
|
|
16
16
|
ToolCallProps,
|
|
@@ -106,6 +106,34 @@ export function ToolCallDisplay({
|
|
|
106
106
|
Partial<AiChatPluginOverrides>
|
|
107
107
|
>("aiChat", {});
|
|
108
108
|
const tr = useAiChatTranslation(localization);
|
|
109
|
+
const { plugins } = useStack();
|
|
110
|
+
if (
|
|
111
|
+
toolName === "readPage" &&
|
|
112
|
+
plugins?.aiChat?.config?.pageContent === true
|
|
113
|
+
) {
|
|
114
|
+
const failed = state === "output-error";
|
|
115
|
+
return (
|
|
116
|
+
<p className="text-xs text-muted-foreground" role="status">
|
|
117
|
+
{failed
|
|
118
|
+
? tr(
|
|
119
|
+
"PAGE_CONTENT_ERROR",
|
|
120
|
+
"aiChat.pageContent.error",
|
|
121
|
+
"Unable to read this page.",
|
|
122
|
+
)
|
|
123
|
+
: state === "output-available"
|
|
124
|
+
? tr(
|
|
125
|
+
"PAGE_CONTENT_READ",
|
|
126
|
+
"aiChat.pageContent.read",
|
|
127
|
+
"Read page content",
|
|
128
|
+
)
|
|
129
|
+
: tr(
|
|
130
|
+
"PAGE_CONTENT_READING",
|
|
131
|
+
"aiChat.pageContent.reading",
|
|
132
|
+
"Reading page content…",
|
|
133
|
+
)}
|
|
134
|
+
</p>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
109
137
|
const displayName = formatToolName(toolName);
|
|
110
138
|
const statusLabel =
|
|
111
139
|
isLoading || state === "input-streaming"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useCallback, useContext, useRef } from "react";
|
|
4
|
+
import { usePluginOverrides, useStack } from "@btst/stack/context";
|
|
5
|
+
import { usePageAIContext } from "./page-ai-context";
|
|
6
|
+
import { resolveAiChatMode, type AiChatPluginOverrides } from "../overrides";
|
|
7
|
+
import type { AiChatAnalyticsEvent } from "../analytics";
|
|
8
|
+
|
|
9
|
+
export const ChatAnalyticsPageKey = createContext<string | undefined>(
|
|
10
|
+
undefined,
|
|
11
|
+
);
|
|
12
|
+
type EventDetails<T> = T extends unknown
|
|
13
|
+
? Omit<T, "pageKey" | "routeName" | "mode">
|
|
14
|
+
: never;
|
|
15
|
+
|
|
16
|
+
export function useChatAnalytics() {
|
|
17
|
+
const { onAnalyticsEvent } = usePluginOverrides<
|
|
18
|
+
AiChatPluginOverrides,
|
|
19
|
+
Partial<AiChatPluginOverrides>
|
|
20
|
+
>("aiChat", {});
|
|
21
|
+
const { plugins } = useStack();
|
|
22
|
+
const pageKey = useContext(ChatAnalyticsPageKey);
|
|
23
|
+
const routeName = usePageAIContext()?.routeName;
|
|
24
|
+
const latest = useRef({
|
|
25
|
+
onAnalyticsEvent,
|
|
26
|
+
pageKey,
|
|
27
|
+
routeName,
|
|
28
|
+
mode: resolveAiChatMode(plugins?.aiChat?.config),
|
|
29
|
+
});
|
|
30
|
+
latest.current = {
|
|
31
|
+
onAnalyticsEvent,
|
|
32
|
+
pageKey,
|
|
33
|
+
routeName,
|
|
34
|
+
mode: resolveAiChatMode(plugins?.aiChat?.config),
|
|
35
|
+
};
|
|
36
|
+
return useCallback((event: EventDetails<AiChatAnalyticsEvent>) => {
|
|
37
|
+
const { onAnalyticsEvent: callback, ...context } = latest.current;
|
|
38
|
+
try {
|
|
39
|
+
void Promise.resolve(callback?.({ ...context, ...event })).catch(
|
|
40
|
+
() => {},
|
|
41
|
+
);
|
|
42
|
+
} catch {
|
|
43
|
+
// Optional instrumentation must never interrupt chat interactions.
|
|
44
|
+
}
|
|
45
|
+
}, []);
|
|
46
|
+
}
|
|
@@ -33,7 +33,8 @@ export interface PageAIContextConfig {
|
|
|
33
33
|
/**
|
|
34
34
|
* Human-readable description of the current page and its content.
|
|
35
35
|
* Injected into the AI system prompt so it understands what the user is looking at.
|
|
36
|
-
*
|
|
36
|
+
* Limited to 16,000 characters server-side. For longer content, pass a page
|
|
37
|
+
* path and configure the backend pageContent resolver.
|
|
37
38
|
*/
|
|
38
39
|
pageDescription: string;
|
|
39
40
|
|
|
@@ -74,6 +75,11 @@ const PageAIAPIContext = createContext<PageAIAPIContextValue | null>(null);
|
|
|
74
75
|
*/
|
|
75
76
|
const PageAIVersionContext = createContext<number>(0);
|
|
76
77
|
|
|
78
|
+
/** Instance-local context used by ChatLayout; undefined inherits page registrations. */
|
|
79
|
+
export const PageAIContextScope = createContext<
|
|
80
|
+
PageAIContextConfig | null | undefined
|
|
81
|
+
>(undefined);
|
|
82
|
+
|
|
77
83
|
/**
|
|
78
84
|
* Provider that enables route-aware AI context across the app.
|
|
79
85
|
*
|
|
@@ -235,6 +241,8 @@ export function usePageAIContext(): PageAIContextConfig | null {
|
|
|
235
241
|
// registers or unregisters context, then read the latest active config.
|
|
236
242
|
useContext(PageAIVersionContext);
|
|
237
243
|
const ctx = useContext(PageAIAPIContext);
|
|
244
|
+
const scoped = useContext(PageAIContextScope);
|
|
245
|
+
if (scoped !== undefined) return scoped;
|
|
238
246
|
if (!ctx) return null;
|
|
239
247
|
return ctx.getActive();
|
|
240
248
|
}
|
|
@@ -86,7 +86,13 @@ export interface AiChatLocalization {
|
|
|
86
86
|
A11Y_CHAT_TITLE: string;
|
|
87
87
|
A11Y_CLEAR_CHAT: string;
|
|
88
88
|
A11Y_CLOSE_CHAT: string;
|
|
89
|
+
A11Y_EXPAND_CHAT: string;
|
|
90
|
+
A11Y_COLLAPSE_CHAT: string;
|
|
89
91
|
A11Y_OPEN_CHAT: string;
|
|
92
|
+
A11Y_DISMISS_CHAT_TIP: string;
|
|
93
|
+
PAGE_CONTENT_READING: string;
|
|
94
|
+
PAGE_CONTENT_READ: string;
|
|
95
|
+
PAGE_CONTENT_ERROR: string;
|
|
90
96
|
A11Y_OPEN_MENU: string;
|
|
91
97
|
A11Y_CLOSE_SIDEBAR: string;
|
|
92
98
|
A11Y_OPEN_SIDEBAR: string;
|
|
@@ -190,7 +196,13 @@ export const AI_CHAT_LOCALIZATION: AiChatLocalization = {
|
|
|
190
196
|
A11Y_CHAT_TITLE: "AI Chat",
|
|
191
197
|
A11Y_CLEAR_CHAT: "Clear chat",
|
|
192
198
|
A11Y_CLOSE_CHAT: "Close chat",
|
|
199
|
+
A11Y_EXPAND_CHAT: "Expand chat",
|
|
200
|
+
A11Y_COLLAPSE_CHAT: "Collapse chat",
|
|
193
201
|
A11Y_OPEN_CHAT: "Open chat",
|
|
202
|
+
A11Y_DISMISS_CHAT_TIP: "Dismiss chat tip",
|
|
203
|
+
PAGE_CONTENT_READING: "Reading page content…",
|
|
204
|
+
PAGE_CONTENT_READ: "Read page content",
|
|
205
|
+
PAGE_CONTENT_ERROR: "Unable to read this page.",
|
|
194
206
|
A11Y_OPEN_MENU: "Open menu",
|
|
195
207
|
A11Y_CLOSE_SIDEBAR: "Close sidebar",
|
|
196
208
|
A11Y_OPEN_SIDEBAR: "Open sidebar",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AiChatAnalyticsEvent } from "./analytics";
|
|
1
2
|
import type { ComponentType } from "react";
|
|
2
3
|
import { normalizePath } from "@btst/stack/client";
|
|
3
4
|
import type { AiChatLocalization } from "./localization";
|
|
@@ -13,6 +14,8 @@ export type AiChatMode = "authenticated" | "public";
|
|
|
13
14
|
export interface AiChatProviderConfig {
|
|
14
15
|
/** Conversation behavior selected by `aiChatClientPlugin()`. */
|
|
15
16
|
readonly mode: AiChatMode;
|
|
17
|
+
/** Whether built-in page reader permission checks and status UI are enabled. */
|
|
18
|
+
readonly pageContent?: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/** Resolve the registered AI Chat client factory mode. */
|
|
@@ -143,6 +146,8 @@ export interface RouteContext {
|
|
|
143
146
|
* plugin-specific components and behavior.
|
|
144
147
|
*/
|
|
145
148
|
export interface AiChatPluginOverrides {
|
|
149
|
+
/** Optional content-free usage telemetry. Exceptions and rejections are isolated from chat. */
|
|
150
|
+
onAnalyticsEvent?: (event: AiChatAnalyticsEvent) => void | Promise<void>;
|
|
146
151
|
/**
|
|
147
152
|
* Function used to upload a file and return its URL.
|
|
148
153
|
* Called for images, PDFs, text files, and other supported file types.
|