cherry-styled-components 0.2.11 → 0.2.13
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 +89 -28
- package/dist/avatar-dropzone.js +8 -8
- package/dist/avatar.d.ts +17 -0
- package/dist/avatar.js +68 -0
- package/dist/button.js +15 -12
- package/dist/callout.d.ts +13 -0
- package/dist/callout.js +121 -0
- package/dist/chat-input.d.ts +18 -0
- package/dist/chat-input.js +257 -0
- package/dist/chat-launcher.d.ts +10 -0
- package/dist/chat-launcher.js +78 -0
- package/dist/chat-message-list.d.ts +12 -0
- package/dist/chat-message-list.js +88 -0
- package/dist/chat-message.d.ts +16 -0
- package/dist/chat-message.js +56 -0
- package/dist/chat-panel.d.ts +25 -0
- package/dist/chat-panel.js +163 -0
- package/dist/chat-provider.d.ts +86 -0
- package/dist/chat-provider.js +295 -0
- package/dist/chat-showcase.d.ts +18 -0
- package/dist/chat-showcase.js +234 -0
- package/dist/chat-sources.d.ts +10 -0
- package/dist/chat-sources.js +41 -0
- package/dist/chat-typing.d.ts +7 -0
- package/dist/chat-typing.js +44 -0
- package/dist/dropzone.js +14 -14
- package/dist/icon-button.js +7 -7
- package/dist/index.d.ts +13 -0
- package/dist/index.js +18 -3
- package/dist/input.js +6 -6
- package/dist/modal.js +6 -6
- package/dist/prose.d.ts +17 -0
- package/dist/prose.js +75 -0
- package/dist/spinner.d.ts +11 -0
- package/dist/spinner.js +21 -0
- package/dist/styled-components/client-theme-provider.js +13 -3
- package/dist/tabs.js +6 -7
- package/dist/theme-toggle.js +5 -5
- package/dist/toast.d.ts +2 -2
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/mixins.d.ts +30 -0
- package/dist/utils/mixins.js +24 -3
- package/dist/utils/use-lock-body-scroll.d.ts +5 -0
- package/dist/utils/use-lock-body-scroll.js +25 -0
- package/dist/utils/use-media-query.d.ts +14 -0
- package/dist/utils/use-media-query.js +28 -0
- package/package.json +15 -14
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { mq } from "./utils/theme.js";
|
|
4
|
+
import { useLockBodyScroll } from "./utils/use-lock-body-scroll.js";
|
|
5
|
+
import { useBelowBreakpoint } from "./utils/use-media-query.js";
|
|
6
|
+
import { Icon } from "./icon.js";
|
|
7
|
+
import { ChatContext } from "./chat-provider.js";
|
|
8
|
+
import { IconButton } from "./icon-button.js";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { forwardRef, useContext, useEffect, useRef } from "react";
|
|
11
|
+
import styled, { css } from "styled-components";
|
|
12
|
+
//#region src/lib/chat-panel.tsx
|
|
13
|
+
var DEFAULT_WIDTH = 420;
|
|
14
|
+
var FOCUSABLE_SELECTOR = "a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
15
|
+
var StyledChatPanel = styled.div.withConfig({
|
|
16
|
+
displayName: "chat-panel__StyledChatPanel",
|
|
17
|
+
componentId: "sc-701cc462-0"
|
|
18
|
+
})([
|
|
19
|
+
`display:flex;flex-direction:column;background:`,
|
|
20
|
+
`;color:`,
|
|
21
|
+
`;& .chat-header{display:flex;flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:12px;min-height:62px;padding:12px 20px;border-bottom:solid 1px `,
|
|
22
|
+
`;flex-shrink:0;}& .chat-header-title{display:flex;align-items:center;gap:12px;color:`,
|
|
23
|
+
`;& h3{margin:0;font-size:`,
|
|
24
|
+
`;line-height:1.2;}& svg{width:20px;height:20px;flex-shrink:0;}}& .chat-header-actions{display:flex;align-items:center;gap:12px;}`,
|
|
25
|
+
``
|
|
26
|
+
], ({ theme }) => theme.colors.light, ({ theme }) => theme.colors.dark, ({ theme }) => theme.colors.grayLight, ({ theme }) => theme.colors.dark, ({ theme }) => theme.fontSizes.h6.xs, ({ $variant, $side, $width, $isVisible, theme }) => {
|
|
27
|
+
if ($variant === "inline") return css([
|
|
28
|
+
`position:relative;width:100%;height:100%;border:solid 1px `,
|
|
29
|
+
`;border-radius:`,
|
|
30
|
+
`;overflow:hidden;`
|
|
31
|
+
], theme.colors.grayLight, theme.spacing.radius.lg);
|
|
32
|
+
if ($variant === "fullscreen") return css([`position:fixed;inset:0;z-index:1000;transition:opacity 0.3s ease,visibility 0s linear 0s;opacity:1;visibility:visible;`, ` @media (prefers-reduced-motion:reduce){transition:none;}`], !$isVisible && css([`opacity:0;visibility:hidden;transition-delay:0s,0.3s;`]));
|
|
33
|
+
return css([
|
|
34
|
+
`position:fixed;top:0;bottom:0;`,
|
|
35
|
+
`:0;width:100%;height:100dvh;z-index:1000;transition:transform 0.3s ease,opacity 0.3s ease,visibility 0s linear 0s;transform:translateX(0);opacity:1;visibility:visible;`,
|
|
36
|
+
` @media (prefers-reduced-motion:reduce){transition:none;}`,
|
|
37
|
+
`{width:`,
|
|
38
|
+
`px;border-`,
|
|
39
|
+
`:solid 1px `,
|
|
40
|
+
`;}`
|
|
41
|
+
], $side, !$isVisible && css([`transform:translateX(`, `);opacity:0;visibility:hidden;transition-delay:0s,0s,0.3s;`], $side === "left" ? "-100%" : "100%"), mq("lg"), $width, $side === "left" ? "right" : "left", theme.colors.grayLight);
|
|
42
|
+
});
|
|
43
|
+
function LocalChatPanel({ children, $variant = "drawer", $side = "right", $width = DEFAULT_WIDTH, $title = "AI Assistant", $titleIcon = "Sparkles", $actions, $hideCloseButton, "aria-label": ariaLabel, ...props }, ref) {
|
|
44
|
+
const { isOpen, close, inputRef } = useContext(ChatContext);
|
|
45
|
+
const panelRef = useRef(null);
|
|
46
|
+
const isBelowLg = useBelowBreakpoint("lg");
|
|
47
|
+
const isOverlay = $variant !== "inline";
|
|
48
|
+
const isVisible = !isOverlay || isOpen;
|
|
49
|
+
const isModal = $variant === "fullscreen" && isOpen || $variant === "drawer" && isOpen && isBelowLg;
|
|
50
|
+
useLockBodyScroll(isModal);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isOverlay || !isOpen) return;
|
|
53
|
+
let frame = null;
|
|
54
|
+
let attempts = 0;
|
|
55
|
+
const tryFocus = () => {
|
|
56
|
+
frame = null;
|
|
57
|
+
const input = inputRef.current;
|
|
58
|
+
const panel = panelRef.current;
|
|
59
|
+
if (!input || !panel) return;
|
|
60
|
+
const active = document.activeElement;
|
|
61
|
+
if (active instanceof HTMLElement && panel.contains(active)) return;
|
|
62
|
+
input.focus();
|
|
63
|
+
if (document.activeElement !== input && attempts++ < 30) frame = requestAnimationFrame(tryFocus);
|
|
64
|
+
};
|
|
65
|
+
frame = requestAnimationFrame(tryFocus);
|
|
66
|
+
return () => {
|
|
67
|
+
if (frame !== null) cancelAnimationFrame(frame);
|
|
68
|
+
};
|
|
69
|
+
}, [
|
|
70
|
+
isOpen,
|
|
71
|
+
isOverlay,
|
|
72
|
+
inputRef
|
|
73
|
+
]);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!isModal || !panelRef.current) return;
|
|
76
|
+
const panel = panelRef.current;
|
|
77
|
+
const inertSiblings = [];
|
|
78
|
+
let current = panel;
|
|
79
|
+
while (current.parentElement) {
|
|
80
|
+
const parent = current.parentElement;
|
|
81
|
+
for (const sibling of Array.from(parent.children)) {
|
|
82
|
+
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
|
|
83
|
+
inertSiblings.push({
|
|
84
|
+
element: sibling,
|
|
85
|
+
wasInert: sibling.inert
|
|
86
|
+
});
|
|
87
|
+
sibling.inert = true;
|
|
88
|
+
}
|
|
89
|
+
if (parent === document.body) break;
|
|
90
|
+
current = parent;
|
|
91
|
+
}
|
|
92
|
+
const containFocus = (event) => {
|
|
93
|
+
if (event.target instanceof Node && !panel.contains(event.target)) inputRef.current?.focus();
|
|
94
|
+
};
|
|
95
|
+
document.addEventListener("focusin", containFocus);
|
|
96
|
+
return () => {
|
|
97
|
+
document.removeEventListener("focusin", containFocus);
|
|
98
|
+
for (const { element, wasInert } of inertSiblings) element.inert = wasInert;
|
|
99
|
+
};
|
|
100
|
+
}, [isModal, inputRef]);
|
|
101
|
+
const handleKeyDown = (event) => {
|
|
102
|
+
if (!isModal) return;
|
|
103
|
+
if (event.key === "Escape") {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
close();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (event.key !== "Tab") return;
|
|
110
|
+
const focusable = Array.from(panelRef.current?.querySelectorAll(FOCUSABLE_SELECTOR) ?? []).filter((element) => element.offsetParent !== null && element.getAttribute("aria-hidden") !== "true");
|
|
111
|
+
if (focusable.length === 0) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const first = focusable[0];
|
|
116
|
+
const last = focusable[focusable.length - 1];
|
|
117
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
last.focus();
|
|
120
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
first.focus();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const showHeader = $title !== null || $actions || isOverlay && !$hideCloseButton;
|
|
126
|
+
return /*#__PURE__*/ jsxs(StyledChatPanel, {
|
|
127
|
+
$variant,
|
|
128
|
+
$side,
|
|
129
|
+
$width,
|
|
130
|
+
$isVisible: isVisible,
|
|
131
|
+
inert: isOverlay ? !isOpen : void 0,
|
|
132
|
+
"aria-hidden": isOverlay ? !isOpen : void 0,
|
|
133
|
+
role: isModal ? "dialog" : isOverlay ? "complementary" : void 0,
|
|
134
|
+
"aria-modal": isModal ? "true" : void 0,
|
|
135
|
+
"aria-label": ariaLabel ?? (typeof $title === "string" ? $title : void 0),
|
|
136
|
+
"data-chat-panel": true,
|
|
137
|
+
onKeyDownCapture: handleKeyDown,
|
|
138
|
+
...props,
|
|
139
|
+
ref: (node) => {
|
|
140
|
+
panelRef.current = node;
|
|
141
|
+
if (typeof ref === "function") ref(node);
|
|
142
|
+
else if (ref) ref.current = node;
|
|
143
|
+
},
|
|
144
|
+
children: [showHeader && /*#__PURE__*/ jsxs("div", {
|
|
145
|
+
className: "chat-header",
|
|
146
|
+
children: [/*#__PURE__*/ jsxs("span", {
|
|
147
|
+
className: "chat-header-title",
|
|
148
|
+
children: [$titleIcon && /*#__PURE__*/ jsx(Icon, { name: $titleIcon }), $title !== null && /*#__PURE__*/ jsx("h3", { children: $title })]
|
|
149
|
+
}), /*#__PURE__*/ jsxs("span", {
|
|
150
|
+
className: "chat-header-actions",
|
|
151
|
+
children: [$actions, isOverlay && !$hideCloseButton && /*#__PURE__*/ jsx(IconButton, {
|
|
152
|
+
onClick: () => close(),
|
|
153
|
+
"aria-label": "Close chat",
|
|
154
|
+
title: "Close chat",
|
|
155
|
+
children: /*#__PURE__*/ jsx(Icon, { name: "X" })
|
|
156
|
+
})]
|
|
157
|
+
})]
|
|
158
|
+
}), children]
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
var ChatPanel = /*#__PURE__*/ forwardRef(LocalChatPanel);
|
|
162
|
+
//#endregion
|
|
163
|
+
export { ChatPanel };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
export type ChatRole = "user" | "assistant";
|
|
3
|
+
export interface ChatSourceData {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
href: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ChatMessageData {
|
|
9
|
+
id: string;
|
|
10
|
+
role: ChatRole;
|
|
11
|
+
/** Rendered as-is: plain text while streaming, rich markup when done. */
|
|
12
|
+
content: React.ReactNode;
|
|
13
|
+
/** Plain-text mirror of `content`, used to build the history for onSend. */
|
|
14
|
+
text?: string;
|
|
15
|
+
sources?: ChatSourceData[];
|
|
16
|
+
}
|
|
17
|
+
export interface ChatHistoryEntry {
|
|
18
|
+
role: ChatRole;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ChatSendContext {
|
|
22
|
+
/** Aborted by reset() and on unmount; pass it to fetch. */
|
|
23
|
+
signal: AbortSignal;
|
|
24
|
+
/** The conversation before this question, oldest first. */
|
|
25
|
+
history: ChatHistoryEntry[];
|
|
26
|
+
/**
|
|
27
|
+
* Creates the assistant reply on the first call and patches the same
|
|
28
|
+
* message on every later call, so streaming updates one bubble and the
|
|
29
|
+
* typing indicator stays put until real content arrives.
|
|
30
|
+
*/
|
|
31
|
+
setAssistant: (content: React.ReactNode, options?: {
|
|
32
|
+
text?: string;
|
|
33
|
+
sources?: ChatSourceData[];
|
|
34
|
+
}) => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The transport, supplied by the app: take the question, talk to a server
|
|
38
|
+
* however you like, and stream the reply through `setAssistant`. A thrown
|
|
39
|
+
* error lands in `error`; an abort is swallowed.
|
|
40
|
+
*/
|
|
41
|
+
export type ChatSendHandler = (question: string, context: ChatSendContext) => void | Promise<void>;
|
|
42
|
+
interface ChatContextValue {
|
|
43
|
+
isOpen: boolean;
|
|
44
|
+
open: (returnFocusTo?: HTMLElement | null) => void;
|
|
45
|
+
close: (restoreFocus?: boolean) => HTMLElement | null;
|
|
46
|
+
toggle: () => void;
|
|
47
|
+
messages: ChatMessageData[];
|
|
48
|
+
input: string;
|
|
49
|
+
setInput: (value: string) => void;
|
|
50
|
+
loading: boolean;
|
|
51
|
+
error: string | null;
|
|
52
|
+
/** Sends `text`, or the current input when omitted. No-op while loading. */
|
|
53
|
+
send: (text?: string) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Opens the panel and hands `question` to the assistant. While a reply is
|
|
56
|
+
* still streaming the question is pre-filled instead, ready to send.
|
|
57
|
+
*/
|
|
58
|
+
ask: (question: string, returnFocusTo?: HTMLElement | null) => void;
|
|
59
|
+
/** Aborts any in-flight reply and restores the initial greeting. */
|
|
60
|
+
reset: () => void;
|
|
61
|
+
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
62
|
+
}
|
|
63
|
+
declare const ChatContext: React.Context<ChatContextValue>;
|
|
64
|
+
declare function useChat(): ChatContextValue;
|
|
65
|
+
export interface ChatProviderProps {
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
/**
|
|
68
|
+
* The transport. Optional when $showcase is on, where the commands can
|
|
69
|
+
* carry a demo by themselves; required for real answers.
|
|
70
|
+
*/
|
|
71
|
+
onSend?: ChatSendHandler;
|
|
72
|
+
/** Assistant message seeded on first open and after reset. Pass null for none. */
|
|
73
|
+
greeting?: React.ReactNode;
|
|
74
|
+
/** How many prior messages the history handed to onSend may contain. */
|
|
75
|
+
historyLimit?: number;
|
|
76
|
+
/** Cmd/Ctrl + this key toggles the panel. Pass null to disable. */
|
|
77
|
+
shortcut?: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* Demo mode: chat input matching a showcase command ("help", "list",
|
|
80
|
+
* "callout", …) is answered locally with a live rendering of that element
|
|
81
|
+
* instead of going to onSend. Anything else still goes to the transport.
|
|
82
|
+
*/
|
|
83
|
+
$showcase?: boolean;
|
|
84
|
+
}
|
|
85
|
+
declare function ChatProvider({ children, onSend, greeting, historyLimit, shortcut, $showcase, }: ChatProviderProps): React.JSX.Element;
|
|
86
|
+
export { ChatContext, ChatProvider, useChat };
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { resolveShowcaseCommand } from "./chat-showcase.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
6
|
+
//#region src/lib/chat-provider.tsx
|
|
7
|
+
var ChatContext = /*#__PURE__*/ createContext({
|
|
8
|
+
isOpen: false,
|
|
9
|
+
open: () => {},
|
|
10
|
+
close: () => null,
|
|
11
|
+
toggle: () => {},
|
|
12
|
+
messages: [],
|
|
13
|
+
input: "",
|
|
14
|
+
setInput: () => {},
|
|
15
|
+
loading: false,
|
|
16
|
+
error: null,
|
|
17
|
+
send: () => {},
|
|
18
|
+
ask: () => {},
|
|
19
|
+
reset: () => {},
|
|
20
|
+
inputRef: { current: null }
|
|
21
|
+
});
|
|
22
|
+
function useChat() {
|
|
23
|
+
return useContext(ChatContext);
|
|
24
|
+
}
|
|
25
|
+
var DEFAULT_GREETING = "Hey there, how can I assist you?";
|
|
26
|
+
var SHOWCASE_GREETING = "Hey there! Type \"help\" to see everything I can render.";
|
|
27
|
+
var GREETING_ID = "chat-greeting";
|
|
28
|
+
var HISTORY_ENTRY_MAX_CHARS = 4e3;
|
|
29
|
+
function ChatProvider({ children, onSend, greeting, historyLimit = 20, shortcut = "i", $showcase = false }) {
|
|
30
|
+
const resolvedGreeting = greeting !== void 0 ? greeting : $showcase ? SHOWCASE_GREETING : DEFAULT_GREETING;
|
|
31
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
32
|
+
const [input, setInput] = useState("");
|
|
33
|
+
const [loading, setLoading] = useState(false);
|
|
34
|
+
const [error, setError] = useState(null);
|
|
35
|
+
const [messages, setMessages] = useState(() => resolvedGreeting === null ? [] : [{
|
|
36
|
+
id: GREETING_ID,
|
|
37
|
+
role: "assistant",
|
|
38
|
+
content: resolvedGreeting,
|
|
39
|
+
text: typeof resolvedGreeting === "string" ? resolvedGreeting : void 0
|
|
40
|
+
}]);
|
|
41
|
+
const inputRef = useRef(null);
|
|
42
|
+
const abortRef = useRef(null);
|
|
43
|
+
const idCounter = useRef(0);
|
|
44
|
+
const isOpenRef = useRef(isOpen);
|
|
45
|
+
const loadingRef = useRef(loading);
|
|
46
|
+
const messagesRef = useRef(messages);
|
|
47
|
+
const onSendRef = useRef(onSend);
|
|
48
|
+
const greetingRef = useRef(resolvedGreeting);
|
|
49
|
+
const inputValueRef = useRef(input);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
isOpenRef.current = isOpen;
|
|
52
|
+
loadingRef.current = loading;
|
|
53
|
+
messagesRef.current = messages;
|
|
54
|
+
inputValueRef.current = input;
|
|
55
|
+
onSendRef.current = onSend;
|
|
56
|
+
greetingRef.current = resolvedGreeting;
|
|
57
|
+
});
|
|
58
|
+
const openerRef = useRef(null);
|
|
59
|
+
const pendingRestoreFocusRef = useRef(null);
|
|
60
|
+
const focusTimeoutRef = useRef(null);
|
|
61
|
+
const restoreFocusTimeoutRef = useRef(null);
|
|
62
|
+
const nextId = useCallback(() => `chat-msg-${idCounter.current++}`, []);
|
|
63
|
+
const greetingMessages = useCallback(() => {
|
|
64
|
+
const currentGreeting = greetingRef.current;
|
|
65
|
+
if (currentGreeting === null || currentGreeting === void 0) return [];
|
|
66
|
+
return [{
|
|
67
|
+
id: GREETING_ID,
|
|
68
|
+
role: "assistant",
|
|
69
|
+
content: currentGreeting,
|
|
70
|
+
text: typeof currentGreeting === "string" ? currentGreeting : void 0
|
|
71
|
+
}];
|
|
72
|
+
}, []);
|
|
73
|
+
const showPanel = useCallback((returnFocusTo) => {
|
|
74
|
+
const pendingOpener = pendingRestoreFocusRef.current;
|
|
75
|
+
if (restoreFocusTimeoutRef.current) {
|
|
76
|
+
clearTimeout(restoreFocusTimeoutRef.current);
|
|
77
|
+
restoreFocusTimeoutRef.current = null;
|
|
78
|
+
}
|
|
79
|
+
pendingRestoreFocusRef.current = null;
|
|
80
|
+
if (!isOpenRef.current) {
|
|
81
|
+
const activeElement = document.activeElement;
|
|
82
|
+
openerRef.current = returnFocusTo?.isConnected === true ? returnFocusTo : pendingOpener?.isConnected === true ? pendingOpener : activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : null;
|
|
83
|
+
}
|
|
84
|
+
isOpenRef.current = true;
|
|
85
|
+
setIsOpen(true);
|
|
86
|
+
if (focusTimeoutRef.current) clearTimeout(focusTimeoutRef.current);
|
|
87
|
+
focusTimeoutRef.current = setTimeout(() => {
|
|
88
|
+
focusTimeoutRef.current = null;
|
|
89
|
+
inputRef.current?.focus();
|
|
90
|
+
}, 0);
|
|
91
|
+
}, []);
|
|
92
|
+
const open = useCallback((returnFocusTo) => {
|
|
93
|
+
showPanel(returnFocusTo);
|
|
94
|
+
setMessages((prev) => prev.length === 0 ? greetingMessages() : prev);
|
|
95
|
+
}, [showPanel, greetingMessages]);
|
|
96
|
+
const close = useCallback((restoreFocus = true) => {
|
|
97
|
+
const pendingOpener = pendingRestoreFocusRef.current;
|
|
98
|
+
if (!restoreFocus && restoreFocusTimeoutRef.current) {
|
|
99
|
+
clearTimeout(restoreFocusTimeoutRef.current);
|
|
100
|
+
restoreFocusTimeoutRef.current = null;
|
|
101
|
+
pendingRestoreFocusRef.current = null;
|
|
102
|
+
}
|
|
103
|
+
if (!isOpenRef.current) return pendingOpener;
|
|
104
|
+
if (focusTimeoutRef.current) {
|
|
105
|
+
clearTimeout(focusTimeoutRef.current);
|
|
106
|
+
focusTimeoutRef.current = null;
|
|
107
|
+
}
|
|
108
|
+
const activeElement = document.activeElement;
|
|
109
|
+
if (activeElement instanceof HTMLElement && activeElement.closest("[data-chat-panel]")) activeElement.blur();
|
|
110
|
+
const opener = openerRef.current;
|
|
111
|
+
openerRef.current = null;
|
|
112
|
+
isOpenRef.current = false;
|
|
113
|
+
setIsOpen(false);
|
|
114
|
+
if (restoreFocusTimeoutRef.current) clearTimeout(restoreFocusTimeoutRef.current);
|
|
115
|
+
if (restoreFocus) {
|
|
116
|
+
pendingRestoreFocusRef.current = opener;
|
|
117
|
+
restoreFocusTimeoutRef.current = setTimeout(() => {
|
|
118
|
+
const focusTarget = pendingRestoreFocusRef.current;
|
|
119
|
+
restoreFocusTimeoutRef.current = null;
|
|
120
|
+
pendingRestoreFocusRef.current = null;
|
|
121
|
+
if (focusTarget?.isConnected) focusTarget.focus();
|
|
122
|
+
}, 0);
|
|
123
|
+
} else pendingRestoreFocusRef.current = null;
|
|
124
|
+
return opener;
|
|
125
|
+
}, []);
|
|
126
|
+
const toggle = useCallback(() => {
|
|
127
|
+
if (isOpenRef.current) close();
|
|
128
|
+
else open();
|
|
129
|
+
}, [close, open]);
|
|
130
|
+
const runSend = useCallback(async (question, returnFocusTo) => {
|
|
131
|
+
setInput("");
|
|
132
|
+
showPanel(returnFocusTo);
|
|
133
|
+
const base = messagesRef.current.length > 0 ? messagesRef.current : greetingMessages();
|
|
134
|
+
if ($showcase) {
|
|
135
|
+
const demo = resolveShowcaseCommand(question);
|
|
136
|
+
if (demo) {
|
|
137
|
+
const withDemo = [
|
|
138
|
+
...base,
|
|
139
|
+
{
|
|
140
|
+
id: nextId(),
|
|
141
|
+
role: "user",
|
|
142
|
+
content: question,
|
|
143
|
+
text: question
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: nextId(),
|
|
147
|
+
role: "assistant",
|
|
148
|
+
content: demo
|
|
149
|
+
}
|
|
150
|
+
];
|
|
151
|
+
messagesRef.current = withDemo;
|
|
152
|
+
setMessages(withDemo);
|
|
153
|
+
setError(null);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const handler = onSendRef.current;
|
|
158
|
+
if (!handler) {
|
|
159
|
+
const hint = $showcase ? "I can only render showcase demos here. Type \"help\" to see the available commands." : "No transport is wired up. Pass onSend to ChatProvider to get answers.";
|
|
160
|
+
const withHint = [
|
|
161
|
+
...base,
|
|
162
|
+
{
|
|
163
|
+
id: nextId(),
|
|
164
|
+
role: "user",
|
|
165
|
+
content: question,
|
|
166
|
+
text: question
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: nextId(),
|
|
170
|
+
role: "assistant",
|
|
171
|
+
content: hint,
|
|
172
|
+
text: hint
|
|
173
|
+
}
|
|
174
|
+
];
|
|
175
|
+
messagesRef.current = withHint;
|
|
176
|
+
setMessages(withHint);
|
|
177
|
+
setError(null);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
setLoading(true);
|
|
181
|
+
loadingRef.current = true;
|
|
182
|
+
setError(null);
|
|
183
|
+
const history = base.filter((message) => (message.text ?? "").trim() !== "").slice(-historyLimit).map((message) => ({
|
|
184
|
+
role: message.role,
|
|
185
|
+
content: message.text.slice(0, HISTORY_ENTRY_MAX_CHARS)
|
|
186
|
+
}));
|
|
187
|
+
const withQuestion = [...base, {
|
|
188
|
+
id: nextId(),
|
|
189
|
+
role: "user",
|
|
190
|
+
content: question,
|
|
191
|
+
text: question
|
|
192
|
+
}];
|
|
193
|
+
messagesRef.current = withQuestion;
|
|
194
|
+
setMessages(withQuestion);
|
|
195
|
+
const controller = new AbortController();
|
|
196
|
+
abortRef.current = controller;
|
|
197
|
+
let assistantId = null;
|
|
198
|
+
const setAssistant = (content, options) => {
|
|
199
|
+
if (assistantId === null) assistantId = nextId();
|
|
200
|
+
const patch = {
|
|
201
|
+
id: assistantId,
|
|
202
|
+
role: "assistant",
|
|
203
|
+
content,
|
|
204
|
+
text: options?.text ?? (typeof content === "string" ? content : void 0),
|
|
205
|
+
sources: options?.sources
|
|
206
|
+
};
|
|
207
|
+
setMessages((prev) => {
|
|
208
|
+
const index = prev.findIndex((message) => message.id === assistantId);
|
|
209
|
+
if (index === -1) return [...prev, patch];
|
|
210
|
+
const next = [...prev];
|
|
211
|
+
next[index] = patch;
|
|
212
|
+
return next;
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
await handler(question, {
|
|
217
|
+
signal: controller.signal,
|
|
218
|
+
history,
|
|
219
|
+
setAssistant
|
|
220
|
+
});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
223
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
224
|
+
} finally {
|
|
225
|
+
if (abortRef.current === controller) abortRef.current = null;
|
|
226
|
+
setLoading(false);
|
|
227
|
+
loadingRef.current = false;
|
|
228
|
+
}
|
|
229
|
+
}, [
|
|
230
|
+
showPanel,
|
|
231
|
+
greetingMessages,
|
|
232
|
+
historyLimit,
|
|
233
|
+
nextId,
|
|
234
|
+
$showcase
|
|
235
|
+
]);
|
|
236
|
+
const send = useCallback((text) => {
|
|
237
|
+
const question = (text ?? inputValueRef.current).trim();
|
|
238
|
+
if (question === "" || loadingRef.current) return;
|
|
239
|
+
runSend(question);
|
|
240
|
+
}, [runSend]);
|
|
241
|
+
const ask = useCallback((question, returnFocusTo) => {
|
|
242
|
+
const trimmed = question.trim();
|
|
243
|
+
if (trimmed === "") return;
|
|
244
|
+
if (loadingRef.current) {
|
|
245
|
+
showPanel(returnFocusTo);
|
|
246
|
+
setInput(trimmed);
|
|
247
|
+
} else runSend(trimmed, returnFocusTo);
|
|
248
|
+
}, [showPanel, runSend]);
|
|
249
|
+
const reset = useCallback(() => {
|
|
250
|
+
abortRef.current?.abort();
|
|
251
|
+
abortRef.current = null;
|
|
252
|
+
setLoading(false);
|
|
253
|
+
loadingRef.current = false;
|
|
254
|
+
setError(null);
|
|
255
|
+
setMessages(greetingMessages());
|
|
256
|
+
}, [greetingMessages]);
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!shortcut) return;
|
|
259
|
+
function handleKeyDown(event) {
|
|
260
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === shortcut) {
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
toggle();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
266
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
267
|
+
}, [shortcut, toggle]);
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
return () => {
|
|
270
|
+
if (focusTimeoutRef.current) clearTimeout(focusTimeoutRef.current);
|
|
271
|
+
if (restoreFocusTimeoutRef.current) clearTimeout(restoreFocusTimeoutRef.current);
|
|
272
|
+
abortRef.current?.abort();
|
|
273
|
+
};
|
|
274
|
+
}, []);
|
|
275
|
+
return /*#__PURE__*/ jsx(ChatContext.Provider, {
|
|
276
|
+
value: {
|
|
277
|
+
isOpen,
|
|
278
|
+
open,
|
|
279
|
+
close,
|
|
280
|
+
toggle,
|
|
281
|
+
messages,
|
|
282
|
+
input,
|
|
283
|
+
setInput,
|
|
284
|
+
loading,
|
|
285
|
+
error,
|
|
286
|
+
send,
|
|
287
|
+
ask,
|
|
288
|
+
reset,
|
|
289
|
+
inputRef
|
|
290
|
+
},
|
|
291
|
+
children
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
export { ChatContext, ChatProvider, useChat };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The showcase command set behind ChatProvider's $showcase prop: type a
|
|
4
|
+
* command into the chat and the reply is a live demo of that element,
|
|
5
|
+
* rendered locally without touching the transport. `help` lists them all.
|
|
6
|
+
*/
|
|
7
|
+
export interface ShowcaseCommand {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
render: () => React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
export declare const showcaseCommands: ShowcaseCommand[];
|
|
13
|
+
/**
|
|
14
|
+
* Matches a chat question against the showcase commands ("help", "/help" and
|
|
15
|
+
* any casing all work) and returns the rendered demo, or null when the input
|
|
16
|
+
* is not a command and should go to the transport.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveShowcaseCommand(input: string): React.ReactNode | null;
|