@promptbook/components 0.101.0-16 → 0.101.0-18

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/esm/index.es.js CHANGED
@@ -21,7 +21,7 @@ const BOOK_LANGUAGE_VERSION = '1.0.0';
21
21
  * @generated
22
22
  * @see https://github.com/webgptorg/promptbook
23
23
  */
24
- const PROMPTBOOK_ENGINE_VERSION = '0.101.0-16';
24
+ const PROMPTBOOK_ENGINE_VERSION = '0.101.0-18';
25
25
  /**
26
26
  * TODO: string_promptbook_version should be constrained to the all versions of Promptbook engine
27
27
  * Note: [💞] Ignore a discrepancy between file name and entity name
@@ -4130,6 +4130,147 @@ const SendIcon = ({ size }) => (jsx("svg", { width: size, height: size, viewBox:
4130
4130
  */
4131
4131
  const TemplateIcon = ({ size }) => (jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "currentColor", children: jsx("path", { d: "M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" }) }));
4132
4132
 
4133
+ /**
4134
+ * Hook for managing auto-scroll behavior in chat components
4135
+ *
4136
+ * This hook provides:
4137
+ * - Automatic scrolling to bottom when new messages arrive (if user is already at bottom)
4138
+ * - Detection of when user scrolls away from bottom
4139
+ * - Scroll-to-bottom functionality with smooth animation
4140
+ * - Mobile-optimized scrolling behavior
4141
+ *
4142
+ * @public exported from `@promptbook/components`
4143
+ */
4144
+ function useChatAutoScroll(config = {}) {
4145
+ const { bottomThreshold = 100, smoothScroll = true, scrollCheckDelay = 100, } = config;
4146
+ const [isAutoScrolling, setIsAutoScrolling] = useState(true);
4147
+ const [isMobile, setIsMobile] = useState(false);
4148
+ const chatMessagesRef = useRef(null);
4149
+ const scrollTimeoutRef = useRef(null);
4150
+ const lastScrollHeightRef = useRef(0);
4151
+ // Detect mobile device
4152
+ useEffect(() => {
4153
+ const checkMobile = () => {
4154
+ const isMobileDevice = window.innerWidth <= 768 ||
4155
+ /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
4156
+ setIsMobile(isMobileDevice);
4157
+ };
4158
+ checkMobile();
4159
+ window.addEventListener('resize', checkMobile);
4160
+ return () => window.removeEventListener('resize', checkMobile);
4161
+ }, []);
4162
+ // Check if user is at the bottom of the chat
4163
+ const checkIfAtBottom = useCallback((element) => {
4164
+ const { scrollTop, scrollHeight, clientHeight } = element;
4165
+ return scrollTop + clientHeight >= scrollHeight - bottomThreshold;
4166
+ }, [bottomThreshold]);
4167
+ // Scroll to bottom function
4168
+ const scrollToBottom = useCallback((behavior = 'smooth') => {
4169
+ const chatMessagesElement = chatMessagesRef.current;
4170
+ if (!chatMessagesElement)
4171
+ return;
4172
+ if (isMobile) {
4173
+ // Mobile-optimized scrolling
4174
+ chatMessagesElement.scrollTo({
4175
+ top: chatMessagesElement.scrollHeight,
4176
+ behavior: smoothScroll ? behavior : 'auto',
4177
+ });
4178
+ }
4179
+ else {
4180
+ // Desktop scrolling
4181
+ if (smoothScroll && behavior === 'smooth') {
4182
+ chatMessagesElement.style.scrollBehavior = 'smooth';
4183
+ chatMessagesElement.scrollTop = chatMessagesElement.scrollHeight;
4184
+ chatMessagesElement.style.scrollBehavior = 'auto';
4185
+ }
4186
+ else {
4187
+ chatMessagesElement.scrollTop = chatMessagesElement.scrollHeight;
4188
+ }
4189
+ }
4190
+ }, [isMobile, smoothScroll]);
4191
+ // Handle scroll events
4192
+ const handleScroll = useCallback((event) => {
4193
+ const element = event.target;
4194
+ if (!element)
4195
+ return;
4196
+ // Clear any pending scroll timeout
4197
+ if (scrollTimeoutRef.current) {
4198
+ clearTimeout(scrollTimeoutRef.current);
4199
+ }
4200
+ // Debounce scroll position check to avoid too frequent updates
4201
+ scrollTimeoutRef.current = setTimeout(() => {
4202
+ const isAtBottom = checkIfAtBottom(element);
4203
+ setIsAutoScrolling(isAtBottom);
4204
+ }, 50);
4205
+ }, [checkIfAtBottom]);
4206
+ // Auto-scroll when messages change (if user is at bottom)
4207
+ const handleMessagesChange = useCallback(() => {
4208
+ const chatMessagesElement = chatMessagesRef.current;
4209
+ if (!chatMessagesElement)
4210
+ return;
4211
+ // Check if this is a new message (scroll height increased)
4212
+ const currentScrollHeight = chatMessagesElement.scrollHeight;
4213
+ const hasNewContent = currentScrollHeight > lastScrollHeightRef.current;
4214
+ lastScrollHeightRef.current = currentScrollHeight;
4215
+ if (!hasNewContent)
4216
+ return;
4217
+ // If user is set to auto-scroll, scroll to bottom
4218
+ if (isAutoScrolling) {
4219
+ // Delay scroll slightly to ensure DOM has updated
4220
+ setTimeout(() => {
4221
+ scrollToBottom('smooth');
4222
+ }, scrollCheckDelay);
4223
+ }
4224
+ }, [isAutoScrolling, scrollToBottom, scrollCheckDelay]);
4225
+ // Ref callback for chat messages container
4226
+ const chatMessagesRefCallback = useCallback((element) => {
4227
+ chatMessagesRef.current = element;
4228
+ if (element) {
4229
+ // Update last scroll height
4230
+ lastScrollHeightRef.current = element.scrollHeight;
4231
+ // If auto-scrolling is enabled, scroll to bottom
4232
+ if (isAutoScrolling) {
4233
+ // Use requestAnimationFrame for smoother initial scroll
4234
+ requestAnimationFrame(() => {
4235
+ scrollToBottom('auto');
4236
+ });
4237
+ }
4238
+ }
4239
+ }, [isAutoScrolling, scrollToBottom]);
4240
+ // Manual scroll to bottom (for button click)
4241
+ const handleScrollToBottomClick = useCallback(() => {
4242
+ setIsAutoScrolling(true);
4243
+ scrollToBottom('smooth');
4244
+ }, [scrollToBottom]);
4245
+ // Force auto-scroll back on (useful for programmatic control)
4246
+ const enableAutoScroll = useCallback(() => {
4247
+ setIsAutoScrolling(true);
4248
+ scrollToBottom('smooth');
4249
+ }, [scrollToBottom]);
4250
+ // Disable auto-scroll (useful for programmatic control)
4251
+ const disableAutoScroll = useCallback(() => {
4252
+ setIsAutoScrolling(false);
4253
+ }, []);
4254
+ // Cleanup timeout on unmount
4255
+ useEffect(() => {
4256
+ return () => {
4257
+ if (scrollTimeoutRef.current) {
4258
+ clearTimeout(scrollTimeoutRef.current);
4259
+ }
4260
+ };
4261
+ }, []);
4262
+ return {
4263
+ isAutoScrolling,
4264
+ chatMessagesRef: chatMessagesRefCallback,
4265
+ handleScroll,
4266
+ handleMessagesChange,
4267
+ scrollToBottom: handleScrollToBottomClick,
4268
+ enableAutoScroll,
4269
+ disableAutoScroll,
4270
+ isMobile,
4271
+ };
4272
+ }
4273
+
4133
4274
  /**
4134
4275
  * Parses markdown buttons in the format [Button Text](?message=Message%20to%20send)
4135
4276
  * Returns both the content without buttons and the extracted buttons
@@ -4318,9 +4459,9 @@ function Chat(props) {
4318
4459
  // exportHeaderMarkdown,
4319
4460
  participants = [], } = props;
4320
4461
  const { onUseTemplate } = props;
4321
- const [isAutoScrolling, setAutoScrolling] = useState(true);
4462
+ // Use the auto-scroll hook
4463
+ const { isAutoScrolling, chatMessagesRef, handleScroll, handleMessagesChange, scrollToBottom, isMobile: isMobileFromHook, } = useChatAutoScroll();
4322
4464
  const textareaRef = useRef(null);
4323
- const chatMessagesRef = useRef(null);
4324
4465
  const buttonSendRef = useRef(null);
4325
4466
  const [ratingModalOpen, setRatingModalOpen] = useState(false);
4326
4467
  const [selectedMessage, setSelectedMessage] = useState(null);
@@ -4333,18 +4474,8 @@ function Chat(props) {
4333
4474
  // const [inputValue, setInputValue] = useState('');
4334
4475
  const [mode] = useState('LIGHT'); // Simplified light/dark mode
4335
4476
  const [ratingConfirmation, setRatingConfirmation] = useState(null);
4336
- const [isMobile, setIsMobile] = useState(false);
4337
- // Detect mobile device
4338
- useEffect(() => {
4339
- const checkMobile = () => {
4340
- const isMobileDevice = window.innerWidth <= 768 ||
4341
- /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
4342
- setIsMobile(isMobileDevice);
4343
- };
4344
- checkMobile();
4345
- window.addEventListener('resize', checkMobile);
4346
- return () => window.removeEventListener('resize', checkMobile);
4347
- }, []);
4477
+ // Use mobile detection from the hook
4478
+ const isMobile = isMobileFromHook;
4348
4479
  useEffect(( /* Focus textarea on page load */) => {
4349
4480
  if (!textareaRef.current) {
4350
4481
  return;
@@ -4470,59 +4601,16 @@ function Chat(props) {
4470
4601
  return { ...message, content: humanizeAiText(message.content) };
4471
4602
  });
4472
4603
  }, [messages, isAiTextHumanized]);
4473
- return (jsxs(Fragment, { children: [ratingConfirmation && jsx("div", { className: styles$1.ratingConfirmation, children: ratingConfirmation }), jsx("div", { className: classNames(className, styles$1.Chat, useChatCssClassName('Chat')), style, children: jsxs("div", { className: classNames(className, styles$1.chatMainFlow, useChatCssClassName('chatMainFlow')), children: [children && jsx("div", { className: classNames(styles$1.chatBar, chatBarCssClassName), children: children }), !isAutoScrolling && (jsx("div", { className: styles$1.scrollToBottomContainer, children: jsx("button", { "data-button-type": "custom", className: classNames(styles$1.scrollToBottom, scrollToBottomCssClassName), onClick: () => {
4474
- const chatMessagesElement = chatMessagesRef.current;
4475
- if (chatMessagesElement === null) {
4476
- return;
4477
- }
4478
- // Mobile-optimized scroll to bottom
4479
- if (isMobile) {
4480
- chatMessagesElement.scrollTo({
4481
- top: chatMessagesElement.scrollHeight,
4482
- behavior: 'smooth',
4483
- });
4484
- }
4485
- else {
4486
- chatMessagesElement.style.scrollBehavior = 'smooth';
4487
- chatMessagesElement.scrollBy(0, 10000);
4488
- chatMessagesElement.style.scrollBehavior = 'auto';
4489
- }
4490
- }, children: jsx(ArrowIcon, { direction: "DOWN", size: 33 }) }) })), isVoiceCalling && (jsx("div", { className: styles$1.voiceCallIndicatorBar, children: jsxs("div", { className: styles$1.voiceCallIndicator, children: [jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }), jsx("span", { children: "Voice call active" }), jsx("div", { className: styles$1.voiceCallPulse })] }) })), jsxs("div", { className: classNames(actionsAlignmentClass), children: [onReset && postprocessedMessages.length !== 0 && (jsxs("button", { className: classNames(styles$1.resetButton), onClick: () => {
4604
+ // Trigger auto-scroll when messages change
4605
+ useEffect(() => {
4606
+ handleMessagesChange();
4607
+ }, [postprocessedMessages, handleMessagesChange]);
4608
+ return (jsxs(Fragment, { children: [ratingConfirmation && jsx("div", { className: styles$1.ratingConfirmation, children: ratingConfirmation }), jsx("div", { className: classNames(className, styles$1.Chat, useChatCssClassName('Chat')), style, children: jsxs("div", { className: classNames(className, styles$1.chatMainFlow, useChatCssClassName('chatMainFlow')), children: [children && jsx("div", { className: classNames(styles$1.chatBar, chatBarCssClassName), children: children }), !isAutoScrolling && (jsx("div", { className: styles$1.scrollToBottomContainer, children: jsx("button", { "data-button-type": "custom", className: classNames(styles$1.scrollToBottom, scrollToBottomCssClassName), onClick: scrollToBottom, children: jsx(ArrowIcon, { direction: "DOWN", size: 33 }) }) })), isVoiceCalling && (jsx("div", { className: styles$1.voiceCallIndicatorBar, children: jsxs("div", { className: styles$1.voiceCallIndicator, children: [jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }), jsx("span", { children: "Voice call active" }), jsx("div", { className: styles$1.voiceCallPulse })] }) })), jsxs("div", { className: classNames(actionsAlignmentClass), children: [onReset && postprocessedMessages.length !== 0 && (jsxs("button", { className: classNames(styles$1.resetButton), onClick: () => {
4491
4609
  if (!confirm(`Do you really want to reset the chat?`)) {
4492
4610
  return;
4493
4611
  }
4494
4612
  onReset();
4495
- }, children: [jsx(ResetIcon, {}), jsx("span", { className: styles$1.resetButtonText, children: "New chat" })] })), onUseTemplate && (jsxs("button", { className: classNames(styles$1.useTemplateButton), onClick: onUseTemplate, children: [jsx("span", { className: styles$1.resetButtonText, children: "Use this template" }), jsx(TemplateIcon, { size: 16 })] }))] }), jsx("div", { className: classNames(styles$1.chatMessages, useChatCssClassName('chatMessages')), ref: (chatMessagesElement) => {
4496
- chatMessagesRef.current = chatMessagesElement;
4497
- if (chatMessagesElement === null) {
4498
- return;
4499
- }
4500
- if (!isAutoScrolling) {
4501
- return;
4502
- }
4503
- // Mobile-optimized scrolling
4504
- if (isMobile) {
4505
- // Delay scroll slightly on mobile for better performance
4506
- requestAnimationFrame(() => {
4507
- chatMessagesElement.scrollTo({
4508
- top: chatMessagesElement.scrollHeight,
4509
- behavior: 'smooth',
4510
- });
4511
- });
4512
- }
4513
- else {
4514
- // Desktop smooth scrolling
4515
- chatMessagesElement.style.scrollBehavior = 'smooth';
4516
- chatMessagesElement.scrollBy(0, 1000);
4517
- chatMessagesElement.style.scrollBehavior = 'auto';
4518
- }
4519
- }, onScroll: (event) => {
4520
- const element = event.target;
4521
- if (!(element instanceof HTMLDivElement)) {
4522
- return;
4523
- }
4524
- setAutoScrolling(element.scrollTop + element.clientHeight > element.scrollHeight - 100);
4525
- }, children: postprocessedMessages.map((message, i) => {
4613
+ }, children: [jsx(ResetIcon, {}), jsx("span", { className: styles$1.resetButtonText, children: "New chat" })] })), onUseTemplate && (jsxs("button", { className: classNames(styles$1.useTemplateButton), onClick: onUseTemplate, children: [jsx("span", { className: styles$1.resetButtonText, children: "Use this template" }), jsx(TemplateIcon, { size: 16 })] }))] }), jsx("div", { className: classNames(styles$1.chatMessages, useChatCssClassName('chatMessages')), ref: chatMessagesRef, onScroll: handleScroll, children: postprocessedMessages.map((message, i) => {
4526
4614
  const participant = participants.find((participant) => participant.name === message.from);
4527
4615
  const avatarSrc = (participant && participant.avatarSrc) || '';
4528
4616
  const color = Color.from((participant && participant.color) || '#ccc');
@@ -5396,6 +5484,53 @@ function BookEditor(props) {
5396
5484
  return (jsx("div", { "data-book-component": "BookEditor", ref: hostRef, className: classNames(styles.BookEditor, isVerbose && styles.isVerbose, className), style: style, children: shadowReady && shadowRootRef.current ? createPortal(editorInner, shadowRootRef.current) : jsx(Fragment, { children: "Loading..." }) }));
5397
5485
  }
5398
5486
 
5487
+ /**
5488
+ * Hook to create a sendMessage function for an <LlmChat/> component WITHOUT needing any React Context.
5489
+ *
5490
+ * Usage pattern:
5491
+ * ```tsx
5492
+ * const sendMessage = useSendMessageToLlmChat();
5493
+ * return (
5494
+ * <>
5495
+ * <button onClick={() => sendMessage('Hello!')}>Hello</button>
5496
+ * <LlmChat llmTools={llmTools} sendMessage={sendMessage} />
5497
+ * </>
5498
+ * );
5499
+ * ```
5500
+ *
5501
+ * - No provider wrapping needed.
5502
+ * - Safe to call before the <LlmChat/> mounts (messages will be queued).
5503
+ * - Keeps DRY by letting <LlmChat/> reuse its internal `handleMessage` logic.
5504
+ *
5505
+ * @public exported from `@promptbook/components`
5506
+ */
5507
+ function useSendMessageToLlmChat() {
5508
+ const ref = useRef(null);
5509
+ if (!ref.current) {
5510
+ let handler = null;
5511
+ const queue = [];
5512
+ const sendMessage = (message) => {
5513
+ if (handler) {
5514
+ // Fire and forget
5515
+ void handler(message);
5516
+ }
5517
+ else {
5518
+ queue.push(message);
5519
+ }
5520
+ };
5521
+ sendMessage._attach = (attachedHandler) => {
5522
+ handler = attachedHandler;
5523
+ // Flush queued messages
5524
+ while (queue.length > 0) {
5525
+ const next = queue.shift();
5526
+ void handler(next);
5527
+ }
5528
+ };
5529
+ ref.current = sendMessage;
5530
+ }
5531
+ return ref.current;
5532
+ }
5533
+
5399
5534
  /**
5400
5535
  * Utility functions for persisting chat conversations in localStorage
5401
5536
  *
@@ -5482,16 +5617,22 @@ ChatPersistence.STORAGE_PREFIX = 'promptbook_chat_';
5482
5617
  * @public exported from `@promptbook/components`
5483
5618
  */
5484
5619
  function LlmChat(props) {
5485
- const { llmTools, persistenceKey, onChange, onReset, ...restProps } = props;
5620
+ const { llmTools, persistenceKey, onChange, onReset, initialMessages, sendMessage, ...restProps } = props;
5486
5621
  // Internal state management
5487
- const [messages, setMessages] = useState([]);
5622
+ const [messages, setMessages] = useState(() => (initialMessages ? [...initialMessages] : []));
5488
5623
  const [tasksProgress, setTasksProgress] = useState([]);
5624
+ /**
5625
+ * Tracks whether the user (or system via persistence restoration) has interacted.
5626
+ * We do NOT persist purely initialMessages until the user sends something.
5627
+ */
5628
+ const hasUserInteractedRef = useRef(false);
5489
5629
  // Load persisted messages on component mount
5490
5630
  useEffect(() => {
5491
5631
  if (persistenceKey && ChatPersistence.isAvailable()) {
5492
5632
  const persistedMessages = ChatPersistence.loadMessages(persistenceKey);
5493
5633
  if (persistedMessages.length > 0) {
5494
5634
  setMessages(persistedMessages);
5635
+ hasUserInteractedRef.current = true; // Persisted conversation exists; allow saving next changes
5495
5636
  // Notify about loaded messages
5496
5637
  if (onChange) {
5497
5638
  onChange(persistedMessages, participants);
@@ -5501,7 +5642,10 @@ function LlmChat(props) {
5501
5642
  }, [persistenceKey]); // Only depend on persistenceKey, not participants or onChange to avoid infinite loops
5502
5643
  // Save messages to localStorage whenever messages change (and persistence is enabled)
5503
5644
  useEffect(() => {
5504
- if (persistenceKey && ChatPersistence.isAvailable() && messages.length > 0) {
5645
+ if (persistenceKey &&
5646
+ ChatPersistence.isAvailable() &&
5647
+ messages.length > 0 &&
5648
+ hasUserInteractedRef.current) {
5505
5649
  ChatPersistence.saveMessages(persistenceKey, messages);
5506
5650
  }
5507
5651
  }, [messages, persistenceKey]);
@@ -5522,6 +5666,7 @@ function LlmChat(props) {
5522
5666
  ], [llmTools.profile, llmTools.title]);
5523
5667
  // Handle user messages and LLM responses
5524
5668
  const handleMessage = useCallback(async (messageContent) => {
5669
+ hasUserInteractedRef.current = true;
5525
5670
  // Add user message
5526
5671
  const userMessage = {
5527
5672
  id: `user_${Date.now()}`,
@@ -5609,6 +5754,7 @@ function LlmChat(props) {
5609
5754
  const handleReset = useCallback(async () => {
5610
5755
  setMessages([]);
5611
5756
  setTasksProgress([]);
5757
+ hasUserInteractedRef.current = false;
5612
5758
  // Clear persisted messages if persistence is enabled
5613
5759
  if (persistenceKey && ChatPersistence.isAvailable()) {
5614
5760
  ChatPersistence.clearMessages(persistenceKey);
@@ -5621,8 +5767,14 @@ function LlmChat(props) {
5621
5767
  onChange([], participants);
5622
5768
  }
5623
5769
  }, [persistenceKey, onReset, onChange, participants]);
5770
+ // Attach internal handler to external sendMessage (from useSendMessageToLlmChat) if provided
5771
+ useEffect(() => {
5772
+ if (sendMessage && sendMessage._attach) {
5773
+ sendMessage._attach(handleMessage);
5774
+ }
5775
+ }, [sendMessage, handleMessage]);
5624
5776
  return (jsx(Chat, { ...restProps, messages, onReset, tasksProgress, participants, onMessage: handleMessage, onReset: handleReset }));
5625
5777
  }
5626
5778
 
5627
- export { ArrowIcon, AvatarChip, AvatarChipFromSource, AvatarProfile, AvatarProfileFromSource, BOOK_LANGUAGE_VERSION, BookEditor, Chat, DEFAULT_BOOK_FONT_CLASS, LlmChat, MockedChat, PROMPTBOOK_ENGINE_VERSION, ResetIcon, SendIcon, TemplateIcon, isMarkdownContent, parseMessageButtons, renderMarkdown };
5779
+ export { ArrowIcon, AvatarChip, AvatarChipFromSource, AvatarProfile, AvatarProfileFromSource, BOOK_LANGUAGE_VERSION, BookEditor, Chat, DEFAULT_BOOK_FONT_CLASS, LlmChat, MockedChat, PROMPTBOOK_ENGINE_VERSION, ResetIcon, SendIcon, TemplateIcon, isMarkdownContent, parseMessageButtons, renderMarkdown, useChatAutoScroll, useSendMessageToLlmChat };
5628
5780
  //# sourceMappingURL=index.es.js.map