@sybilion/uilib 1.3.12 → 1.3.15

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.
Files changed (58) hide show
  1. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +4 -1
  2. package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +7 -3
  3. package/dist/esm/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.js +39 -0
  4. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPromptAttachments.js +7 -1
  5. package/dist/esm/components/ui/Chat/ChatSheet/ChatSheet.js +4 -1
  6. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +6 -3
  7. package/dist/esm/components/ui/Chat/buildChatSendMessagePayload.js +70 -0
  8. package/dist/esm/components/ui/Chat/chatAttachmentAccept.js +20 -1
  9. package/dist/esm/components/ui/Chat/chatAttachmentExtract.js +11 -1
  10. package/dist/esm/components/ui/Chat/chatDocxExtract.js +17 -0
  11. package/dist/esm/components/ui/Chat/chatXlsxExtract.js +34 -0
  12. package/dist/esm/components/ui/Chat/sanitizeAttachmentFilename.js +14 -0
  13. package/dist/esm/components/ui/Chat/userTextFileAttachments.js +6 -0
  14. package/dist/esm/contexts/chat-context.js +8 -3
  15. package/dist/esm/index.js +2 -0
  16. package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +8 -8
  17. package/dist/esm/types/src/components/ui/Chat/ChatMessage/ChatMessage.d.ts +1 -1
  18. package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.d.ts +4 -0
  19. package/dist/esm/types/src/components/ui/Chat/ChatSheet/ChatSheet.d.ts +1 -1
  20. package/dist/esm/types/src/components/ui/Chat/buildChatSendMessagePayload.d.ts +10 -0
  21. package/dist/esm/types/src/components/ui/Chat/buildChatSendMessagePayload.test.d.ts +1 -0
  22. package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.d.ts +3 -1
  23. package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.test.d.ts +1 -0
  24. package/dist/esm/types/src/components/ui/Chat/chatDocxExtract.d.ts +2 -0
  25. package/dist/esm/types/src/components/ui/Chat/chatDocxExtract.test.d.ts +1 -0
  26. package/dist/esm/types/src/components/ui/Chat/chatXlsxExtract.d.ts +2 -0
  27. package/dist/esm/types/src/components/ui/Chat/chatXlsxExtract.test.d.ts +1 -0
  28. package/dist/esm/types/src/components/ui/Chat/index.d.ts +3 -1
  29. package/dist/esm/types/src/components/ui/Chat/sanitizeAttachmentFilename.d.ts +2 -0
  30. package/dist/esm/types/src/components/ui/Chat/userTextFileAttachments.d.ts +3 -0
  31. package/dist/esm/types/src/contexts/chat-context.d.ts +3 -3
  32. package/dist/esm/types/tests/setup.d.ts +1 -0
  33. package/package.json +4 -2
  34. package/src/components/ui/Chat/Chat.types.ts +8 -8
  35. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +4 -1
  36. package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +12 -5
  37. package/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.tsx +50 -0
  38. package/src/components/ui/Chat/ChatPrompt/ChatPromptAttachments.tsx +9 -1
  39. package/src/components/ui/Chat/ChatSheet/ChatSheet.tsx +6 -0
  40. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +10 -3
  41. package/src/components/ui/Chat/buildChatSendMessagePayload.test.ts +116 -0
  42. package/src/components/ui/Chat/buildChatSendMessagePayload.ts +82 -0
  43. package/src/components/ui/Chat/chatAttachmentAccept.test.ts +78 -0
  44. package/src/components/ui/Chat/chatAttachmentAccept.ts +25 -0
  45. package/src/components/ui/Chat/chatAttachmentExtract.ts +13 -1
  46. package/src/components/ui/Chat/chatDocxExtract.test.ts +40 -0
  47. package/src/components/ui/Chat/chatDocxExtract.ts +19 -0
  48. package/src/components/ui/Chat/chatXlsxExtract.test.ts +72 -0
  49. package/src/components/ui/Chat/chatXlsxExtract.ts +43 -0
  50. package/src/components/ui/Chat/index.ts +7 -1
  51. package/src/components/ui/Chat/sanitizeAttachmentFilename.ts +15 -0
  52. package/src/components/ui/Chat/userTextFileAttachments.ts +8 -0
  53. package/src/contexts/chat-context.tsx +12 -7
  54. package/src/docs/pages/ChatAttachmentsDropzonePage.tsx +14 -20
  55. package/src/docs/pages/ChatUserCsvAttachmentPage.tsx +7 -5
  56. package/dist/esm/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.js +0 -10
  57. package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.d.ts +0 -4
  58. package/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.tsx +0 -27
@@ -30,6 +30,9 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
30
30
  if (items.length > 0) {
31
31
  setPendingAttachments(prev => [...prev, ...items]);
32
32
  }
33
+ })
34
+ .catch(() => {
35
+ // Extraction failed (parse error, size limit, etc.); skip staging.
33
36
  })
34
37
  .finally(() => setIsExtractingAttachments(false));
35
38
  }, [allowPdfAttachments, promptBusy]);
@@ -65,7 +68,7 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
65
68
  }, [isEmpty, messages.length]);
66
69
  return (jsxs("div", { className: S.root, children: [showResizeHandle && resizeHandle ? (jsx(PanelResizeHandle, { edge: "leading", isActive: resizeHandle.isActive, startWidthPx: resizeHandle.startWidthPx, getShellWidth: resizeHandle.getShellWidth, onDragWidth: resizeHandle.onDragWidth, onDragComplete: resizeHandle.onDragComplete, className: cn(SidebarStem.sidebarResizeHandle, S.chatResizeHandle) })) : null, jsx("div", { className: S.panelHeader, children: onClose ? (jsx(Button, { type: "button", variant: "ghost", icon: true, className: S.panelClose, "aria-label": "Close chat", onClick: onClose, children: jsx(X, { className: "size-4" }) })) : null }), jsxs("div", { className: S.content, children: [attachmentsDropzoneEnabled ? (jsx(DropZone, { accept: attachmentAccept, label: "Drop text files to attach", multiple: true, ghost: true, overlayScope: "container", disabled: promptBusy, className: S.attachmentDropzone, onFiles: handleAttachmentFiles })) : null, jsxs(Chat, { isEmpty: isEmpty, scopeId: effectiveScopeId, onChatDeleted: onChatDeleted, children: [isEmpty ? (jsxs(Fragment, { children: [jsx(Chat.EmptyState, { ...emptyState }), renderPresets('fixed')] })) : (jsx("div", { className: S.scrollWrapper, children: jsxs(Scroll, { y: true, yScrollbarClassName: S.scrollbar, className: S.scroll, innerClassName: S.scrollInner, offset: { y: { before: 56, after: 180 } }, fadeSize: "m", autoHide: true, ref: scrollRef, children: [messages.map((msg, index, arr) => {
67
70
  const isLast = index === arr.length - 1;
68
- return (jsx(Chat.Message, { role: msg.role, text: msg.text, userCsvAttachment: msg.userCsvAttachment, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: isLoading, isLastMessage: isLast, scriptContinue: isLast && scriptContinueLabel
71
+ return (jsx(Chat.Message, { role: msg.role, text: msg.text, userTextFileAttachments: msg.userTextFileAttachments, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: isLoading, isLastMessage: isLast, scriptContinue: isLast && scriptContinueLabel
69
72
  ? { label: scriptContinueLabel }
70
73
  : undefined, onScriptContinue: isLast && scriptContinueLabel
71
74
  ? onScriptContinue
@@ -3,14 +3,18 @@ import cn from 'classnames';
3
3
  import { InteractiveContent } from '../../InteractiveContent/InteractiveContent.js';
4
4
  import { TextShimmer } from '../../TextShimmer/TextShimmer.js';
5
5
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
6
+ import { userTextFileAttachmentsFromMessage } from '../userTextFileAttachments.js';
6
7
  import { AgentMessageContent } from './AgentMessageContent.js';
7
8
  import S from './ChatMessage.styl.js';
8
- import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble.js';
9
+ import { UserTextFileAttachmentBubble } from './UserTextFileAttachmentBubble.js';
9
10
 
10
- function ChatMessage({ role, text, userCsvAttachment, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage = true, scriptContinue, onScriptContinue, renderMessageChart, }) {
11
+ function ChatMessage({ role, text, userTextFileAttachments, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage = true, scriptContinue, onScriptContinue, renderMessageChart, }) {
12
+ const fileAttachments = userTextFileAttachmentsFromMessage({
13
+ userTextFileAttachments,
14
+ });
11
15
  const isAssistant = role === MessageRole.ASSISTANT;
12
16
  const isSystem = role === MessageRole.SYSTEM;
13
- return (jsx("div", { className: cn(S.root, S[`role-${role}`]), children: isSystem ? (jsx("div", { className: S.text, children: text === GENERATING_DASHBOARD_SYSTEM_TEXT ? (jsx(TextShimmer, { as: "span", children: text })) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: S.text, children: jsx(InteractiveContent, { text: text }) }), userCsvAttachment ? (jsx(UserCsvAttachmentBubble, { attachment: userCsvAttachment })) : null] })) }));
17
+ return (jsx("div", { className: cn(S.root, S[`role-${role}`]), children: isSystem ? (jsx("div", { className: S.text, children: text === GENERATING_DASHBOARD_SYSTEM_TEXT ? (jsx(TextShimmer, { as: "span", children: text })) : (text) })) : isAssistant ? (jsx(AgentMessageContent, { text: text, onQuickReply: onQuickReply, suppressedQuickReplyKeys: suppressedQuickReplyKeys, quickReplyDisabled: quickReplyDisabled, isLastMessage: isLastMessage, scriptContinue: scriptContinue, onScriptContinue: onScriptContinue, renderMessageChart: renderMessageChart })) : (jsxs("div", { className: S.userColumn, children: [jsx("div", { className: S.text, children: jsx(InteractiveContent, { text: text }) }), fileAttachments.map(attachment => (jsx(UserTextFileAttachmentBubble, { attachment: attachment }, `${attachment.displayName}:${attachment.filename}`)))] })) }));
14
18
  }
15
19
 
16
20
  export { ChatMessage };
@@ -0,0 +1,39 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+ import { FileChip } from '../../FileChip/FileChip.js';
3
+ import { downloadTextFile } from '../../../../utils/downloadTextFile.js';
4
+
5
+ function formatFromFilename(filename) {
6
+ const lower = filename.toLowerCase();
7
+ if (lower.endsWith('.csv'))
8
+ return 'csv';
9
+ if (lower.endsWith('.pdf'))
10
+ return 'pdf';
11
+ if (lower.endsWith('.xlsx'))
12
+ return 'text';
13
+ return 'text';
14
+ }
15
+ function mimeForFormat(format) {
16
+ if (format === 'csv')
17
+ return 'text/csv;charset=utf-8';
18
+ if (format === 'pdf')
19
+ return 'application/pdf';
20
+ return 'text/plain;charset=utf-8';
21
+ }
22
+ function hintForFormat(format, filename) {
23
+ const lower = filename.toLowerCase();
24
+ if (format === 'csv')
25
+ return 'Download .CSV file';
26
+ if (format === 'pdf')
27
+ return 'Download file';
28
+ if (lower.endsWith('.docx'))
29
+ return 'Download Word document';
30
+ if (lower.endsWith('.xlsx'))
31
+ return 'Download spreadsheet';
32
+ return 'Download text file';
33
+ }
34
+ function UserTextFileAttachmentBubble({ attachment, }) {
35
+ const format = formatFromFilename(attachment.filename);
36
+ return (jsx(FileChip, { name: attachment.displayName, format: format, hint: hintForFormat(format, attachment.filename), onClick: () => downloadTextFile(attachment.content, attachment.filename, mimeForFormat(format)) }));
37
+ }
38
+
39
+ export { UserTextFileAttachmentBubble };
@@ -5,7 +5,13 @@ import S from './ChatPrompt.styl.js';
5
5
  function ChatPromptAttachments({ attachments, onRemove, disabled = false, }) {
6
6
  if (attachments.length === 0)
7
7
  return null;
8
- return (jsx("div", { className: S.attachments, children: attachments.map((item, index) => (jsx(FileChip, { className: S.attachmentItem, name: item.file.name, format: item.kind === 'pdf' ? 'pdf' : 'text', hint: item.kind === 'pdf' ? 'PDF' : 'Text file', onRemove: () => onRemove(index), disabled: disabled }, `${item.file.name}-${index}`))) }));
8
+ return (jsx("div", { className: S.attachments, children: attachments.map((item, index) => (jsx(FileChip, { className: S.attachmentItem, name: item.file.name, format: item.kind === 'pdf' ? 'pdf' : 'text', hint: item.kind === 'pdf'
9
+ ? 'PDF'
10
+ : item.kind === 'docx'
11
+ ? 'Word document'
12
+ : item.kind === 'xlsx'
13
+ ? 'Spreadsheet'
14
+ : 'Text file', onRemove: () => onRemove(index), disabled: disabled }, `${item.file.name}-${index}`))) }));
9
15
  }
10
16
 
11
17
  export { ChatPromptAttachments };
@@ -4,7 +4,7 @@ import { Button } from '../../Button/Button.js';
4
4
  import { ChatChrome } from '../ChatChrome/ChatChrome.js';
5
5
  import { useChatPanelChromeModel } from './useChatPanelChromeModel.js';
6
6
 
7
- function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, inline = false, }) {
7
+ function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, inline = false, }) {
8
8
  const model = useChatPanelChromeModel({
9
9
  embedAsPage: inline,
10
10
  presets,
@@ -14,6 +14,9 @@ function ChatSheet({ triggerLabel = 'Open Chat', triggerAriaLabel, actionsRef, r
14
14
  onGenerateDashboard,
15
15
  renderMessageChart,
16
16
  emptyState,
17
+ allowedAttachments,
18
+ allowPdfAttachments,
19
+ onAttachmentsDropped,
17
20
  });
18
21
  if (actionsRef) {
19
22
  actionsRef.current = {
@@ -2,6 +2,7 @@ import { jsx } from 'react/jsx-runtime';
2
2
  import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
3
3
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
4
4
  import { isGraphIntakeAssistantStepComplete, matchUserTextToQuickReply, parseScriptLine, textHasQuickReplyMarkers, branchKeysUsedFromChatHistory, branchKeysUsedByUserMessages, extractQuickReplyLabelKeyPairsFromText, entryBranchKeyBeforeLastAssistant, isPresetScriptGraph, branchesFromPresetScriptGraph } from '../ChatMessage/presetScript.js';
5
+ import { buildChatSendMessagePayload, displayTextFromSendPayload } from '../buildChatSendMessagePayload.js';
5
6
  import { usedPresetIdsFromMessages, formatChatTranscript } from '../chat-preset-utils.js';
6
7
  import { useChatsForScopeId, useChat, useChatOutboundPending, isChatEmpty } from '../../../../contexts/chat-context.js';
7
8
  import useEvent from '../../../../hooks/useEvent.js';
@@ -296,10 +297,11 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
296
297
  onMessage,
297
298
  onScriptComplete,
298
299
  ]);
299
- const handlePromptSubmit = useCallback(async (message) => {
300
+ const handlePromptSubmit = useCallback(async (message, attachments) => {
300
301
  const chatId = currentChatId;
301
302
  if (!chatId)
302
303
  return;
304
+ const stagedAttachments = attachments ?? [];
303
305
  const quickBranches = quickReplyBranchesByChat[chatId];
304
306
  const graphActive = Boolean(quickBranches && Object.keys(quickBranches).length > 0);
305
307
  const intake = intakeByChatId[chatId];
@@ -402,8 +404,9 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
402
404
  try {
403
405
  if (chatId)
404
406
  endLocalDemoFlow(chatId);
405
- await sendMessage(message);
406
- onMessage?.(message);
407
+ const payload = buildChatSendMessagePayload(message, stagedAttachments);
408
+ await sendMessage(payload);
409
+ onMessage?.(displayTextFromSendPayload(payload));
407
410
  }
408
411
  catch (error) {
409
412
  logger.error('Error sending chat message:', error);
@@ -0,0 +1,70 @@
1
+ import { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename.js';
2
+
3
+ function defaultExtForAttachment(item) {
4
+ const name = item.file.name.toLowerCase();
5
+ if (item.kind === 'pdf' || name.endsWith('.pdf'))
6
+ return 'pdf';
7
+ if (item.kind === 'docx' || name.endsWith('.docx'))
8
+ return 'docx';
9
+ if (item.kind === 'xlsx' || name.endsWith('.xlsx'))
10
+ return 'xlsx';
11
+ if (name.endsWith('.csv'))
12
+ return 'csv';
13
+ if (name.endsWith('.json'))
14
+ return 'json';
15
+ if (name.endsWith('.md') || name.endsWith('.markdown'))
16
+ return 'md';
17
+ if (name.endsWith('.html') || name.endsWith('.htm'))
18
+ return 'html';
19
+ if (name.endsWith('.xml'))
20
+ return 'xml';
21
+ if (name.endsWith('.yaml') || name.endsWith('.yml'))
22
+ return 'yaml';
23
+ if (name.endsWith('.tsv'))
24
+ return 'tsv';
25
+ if (name.endsWith('.ics'))
26
+ return 'ics';
27
+ return 'txt';
28
+ }
29
+ function dropItemToUserAttachment(item) {
30
+ const ext = defaultExtForAttachment(item);
31
+ return {
32
+ displayName: item.file.name,
33
+ filename: sanitizeAttachmentFilename(item.file.name, ext),
34
+ content: item.text,
35
+ };
36
+ }
37
+ function buildApiMessage(displayText, attachments) {
38
+ const parts = [
39
+ displayText,
40
+ ...attachments.map(item => item.text.trim()).filter(Boolean),
41
+ ].filter(Boolean);
42
+ return parts.join('\n\n');
43
+ }
44
+ /** Resolve file attachments on a send payload. */
45
+ function normalizeUserTextFileAttachments(payload) {
46
+ return payload.userTextFileAttachments ?? [];
47
+ }
48
+ /**
49
+ * Build `sendMessage` input from composer text and staged drop items.
50
+ * Returns a plain string when there are no attachments.
51
+ */
52
+ function buildChatSendMessagePayload(displayText, attachments) {
53
+ const trimmed = displayText.trim();
54
+ if (attachments.length === 0) {
55
+ return trimmed;
56
+ }
57
+ const userTextFileAttachments = attachments.map(dropItemToUserAttachment);
58
+ const resolvedDisplayText = trimmed.length > 0 ? trimmed : userTextFileAttachments[0].displayName;
59
+ return {
60
+ apiMessage: buildApiMessage(resolvedDisplayText, attachments),
61
+ displayText: resolvedDisplayText,
62
+ userTextFileAttachments,
63
+ };
64
+ }
65
+ /** Display text from a string or structured send payload. */
66
+ function displayTextFromSendPayload(message) {
67
+ return typeof message === 'string' ? message : message.displayText;
68
+ }
69
+
70
+ export { buildChatSendMessagePayload, displayTextFromSendPayload, normalizeUserTextFileAttachments };
@@ -24,6 +24,10 @@ const TEXT_ATTACHMENT_ACCEPT_PARTS = [
24
24
  '.tsv',
25
25
  'text/calendar',
26
26
  '.ics',
27
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
28
+ '.docx',
29
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
30
+ '.xlsx',
27
31
  ];
28
32
  const PDF_ATTACHMENT_ACCEPT_PARTS = ['application/pdf', '.pdf'];
29
33
  const TEXT_ATTACHMENT_ACCEPT_SET = new Set(TEXT_ATTACHMENT_ACCEPT_PARTS.map(part => part.toLowerCase()));
@@ -46,9 +50,24 @@ function isPdfFile(file) {
46
50
  return true;
47
51
  return file.name.toLowerCase().endsWith('.pdf');
48
52
  }
53
+ function isDocxFile(file) {
54
+ const type = file.type.toLowerCase();
55
+ if (type ===
56
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
57
+ return true;
58
+ }
59
+ return file.name.toLowerCase().endsWith('.docx');
60
+ }
61
+ function isXlsxFile(file) {
62
+ const type = file.type.toLowerCase();
63
+ if (type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
64
+ return true;
65
+ }
66
+ return file.name.toLowerCase().endsWith('.xlsx');
67
+ }
49
68
  function isAttachmentsDropzoneEnabled(allowedAttachments, allowPdfAttachments) {
50
69
  return (filterToTextAttachments(allowedAttachments).length > 0 ||
51
70
  Boolean(allowPdfAttachments));
52
71
  }
53
72
 
54
- export { PDF_ATTACHMENT_ACCEPT_PARTS, TEXT_ATTACHMENT_ACCEPT_PARTS, buildAcceptAttr, filterToTextAttachments, isAttachmentsDropzoneEnabled, isPdfFile };
73
+ export { PDF_ATTACHMENT_ACCEPT_PARTS, TEXT_ATTACHMENT_ACCEPT_PARTS, buildAcceptAttr, filterToTextAttachments, isAttachmentsDropzoneEnabled, isDocxFile, isPdfFile, isXlsxFile };
@@ -1,5 +1,7 @@
1
- import { isPdfFile } from './chatAttachmentAccept.js';
1
+ import { isPdfFile, isDocxFile, isXlsxFile } from './chatAttachmentAccept.js';
2
+ import { extractDocxFileToText } from './chatDocxExtract.js';
2
3
  import { extractPdfFileToText } from './chatPdfExtract.js';
4
+ import { extractXlsxFileToText } from './chatXlsxExtract.js';
3
5
 
4
6
  function readTextFile(file) {
5
7
  return new Promise((resolve, reject) => {
@@ -17,6 +19,14 @@ async function extractChatAttachmentItems(files, allowPdfAttachments) {
17
19
  const text = await extractPdfFileToText(file);
18
20
  return { file, text, kind: 'pdf' };
19
21
  }
22
+ if (isDocxFile(file)) {
23
+ const text = await extractDocxFileToText(file);
24
+ return { file, text, kind: 'docx' };
25
+ }
26
+ if (isXlsxFile(file)) {
27
+ const text = await extractXlsxFileToText(file);
28
+ return { file, text, kind: 'xlsx' };
29
+ }
20
30
  const text = await readTextFile(file);
21
31
  return { file, text, kind: 'text' };
22
32
  }));
@@ -0,0 +1,17 @@
1
+ /** Best-effort plain text from DOCX via mammoth (loaded on demand). */
2
+ async function extractDocxFileToText(file) {
3
+ const mammoth = await import('mammoth');
4
+ const result = await mammoth.extractRawText({
5
+ arrayBuffer: await file.arrayBuffer(),
6
+ });
7
+ const errors = result.messages.filter(m => m.type === 'error');
8
+ if (errors.length > 0) {
9
+ const detail = errors.map(m => m.message).join('; ');
10
+ throw new Error(detail
11
+ ? `Failed to read ${file.name}: ${detail}`
12
+ : `Failed to read ${file.name}`);
13
+ }
14
+ return result.value.trim();
15
+ }
16
+
17
+ export { extractDocxFileToText };
@@ -0,0 +1,34 @@
1
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
2
+ const MAX_SHEETS = 20;
3
+ const MAX_CSV_CHARS_PER_SHEET = 500_000;
4
+ function truncateCsv(csv) {
5
+ if (csv.length <= MAX_CSV_CHARS_PER_SHEET)
6
+ return csv;
7
+ return `${csv.slice(0, MAX_CSV_CHARS_PER_SHEET).trimEnd()}…`;
8
+ }
9
+ /** Best-effort plain text from XLSX; one CSV block per sheet (xlsx loaded on demand). */
10
+ async function extractXlsxFileToText(file) {
11
+ const buffer = new Uint8Array(await file.arrayBuffer());
12
+ if (buffer.byteLength > MAX_FILE_BYTES) {
13
+ throw new Error(`${file.name} is too large (max ${MAX_FILE_BYTES / (1024 * 1024)} MB)`);
14
+ }
15
+ const XLSX = await import('xlsx');
16
+ const workbook = XLSX.read(buffer, { type: 'array' });
17
+ const sheetNames = workbook.SheetNames.slice(0, MAX_SHEETS);
18
+ const sheetTexts = [];
19
+ for (const sheetName of sheetNames) {
20
+ const sheet = workbook.Sheets[sheetName];
21
+ if (!sheet)
22
+ continue;
23
+ const csv = truncateCsv(XLSX.utils.sheet_to_csv(sheet, { blankrows: false }).trim());
24
+ if (csv) {
25
+ sheetTexts.push(`## Sheet ${sheetName}\n\n${csv}`);
26
+ }
27
+ }
28
+ if (workbook.SheetNames.length > MAX_SHEETS) {
29
+ sheetTexts.push(`_(Only the first ${MAX_SHEETS} of ${workbook.SheetNames.length} sheets were included.)_`);
30
+ }
31
+ return sheetTexts.join('\n\n');
32
+ }
33
+
34
+ export { extractXlsxFileToText };
@@ -0,0 +1,14 @@
1
+ /** Safe download filename from a display title or original file name. */
2
+ function sanitizeAttachmentFilename(displayTitle, defaultExt = 'txt') {
3
+ const cleaned = displayTitle
4
+ .replace(/[/\\?%*:|"<>]/g, '-')
5
+ .replace(/\s+/g, ' ')
6
+ .trim()
7
+ .slice(0, 120);
8
+ const base = cleaned.length > 0 ? cleaned : 'attachment';
9
+ const ext = defaultExt.startsWith('.') ? defaultExt : `.${defaultExt}`;
10
+ const pattern = new RegExp(`${ext.replace('.', '\\.')}$`, 'i');
11
+ return pattern.test(base) ? base : `${base}${ext}`;
12
+ }
13
+
14
+ export { sanitizeAttachmentFilename };
@@ -0,0 +1,6 @@
1
+ /** File attachments on a stored message. */
2
+ function userTextFileAttachmentsFromMessage(message) {
3
+ return message.userTextFileAttachments ?? [];
4
+ }
5
+
6
+ export { userTextFileAttachmentsFromMessage };
@@ -1,6 +1,7 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import { createContext, useState, useCallback, useEffect, useContext, useMemo } from 'react';
3
3
  import { MessageRole } from '../components/ui/Chat/Chat.types.js';
4
+ import { normalizeUserTextFileAttachments } from '../components/ui/Chat/buildChatSendMessagePayload.js';
4
5
  import { stripJsonDashboardFences } from '../lib/dashboard-spec/stripJsonDashboardFences.js';
5
6
  import { LS } from '@homecode/ui';
6
7
 
@@ -181,13 +182,17 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
181
182
  return undefined;
182
183
  addScopeIdToRegistry(scopeId);
183
184
  const storedText = stripJsonDashboardFences(text);
184
- const attachment = role === MessageRole.USER ? options?.userCsvAttachment : undefined;
185
+ const attachments = role === MessageRole.USER
186
+ ? options?.userTextFileAttachments
187
+ : undefined;
185
188
  const newMessage = {
186
189
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
187
190
  role,
188
191
  text: storedText,
189
192
  timestamp: Date.now(),
190
- ...(attachment ? { userCsvAttachment: attachment } : {}),
193
+ ...(attachments?.length
194
+ ? { userTextFileAttachments: attachments }
195
+ : {}),
191
196
  };
192
197
  setChats(prev => {
193
198
  const scopeChats = prev[scopeId] ?? [];
@@ -232,7 +237,7 @@ function ChatProvider({ children, userSwitchKey, sendChatMessage: sendChatMessag
232
237
  }
233
238
  else {
234
239
  addMessage(scopeId, targetChatId, MessageRole.USER, message.displayText, {
235
- userCsvAttachment: message.userCsvAttachment,
240
+ userTextFileAttachments: normalizeUserTextFileAttachments(message),
236
241
  });
237
242
  }
238
243
  const pendingChatSessionId = targetChatId;
package/dist/esm/index.js CHANGED
@@ -23,6 +23,8 @@ export { Chat } from './components/ui/Chat/Chat.js';
23
23
  export { usedPresetIdsFromMessages } from './components/ui/Chat/chat-preset-utils.js';
24
24
  export { ChatChrome } from './components/ui/Chat/ChatChrome/ChatChrome.js';
25
25
  export { TEXT_ATTACHMENT_ACCEPT_PARTS, filterToTextAttachments } from './components/ui/Chat/chatAttachmentAccept.js';
26
+ export { buildChatSendMessagePayload, displayTextFromSendPayload, normalizeUserTextFileAttachments } from './components/ui/Chat/buildChatSendMessagePayload.js';
27
+ export { sanitizeAttachmentFilename } from './components/ui/Chat/sanitizeAttachmentFilename.js';
26
28
  export { ChatSheet } from './components/ui/Chat/ChatSheet/ChatSheet.js';
27
29
  export { useChatPanelChromeModel } from './components/ui/Chat/ChatSheet/useChatPanelChromeModel.js';
28
30
  export { ChatMessage } from './components/ui/Chat/ChatMessage/ChatMessage.js';
@@ -7,24 +7,24 @@ export declare enum MessageRole {
7
7
  }
8
8
  /** System placeholder while dashboard generation runs (must match ChatSheet `addMessage` text). */
9
9
  export declare const GENERATING_DASHBOARD_SYSTEM_TEXT = "Generating dashboard\u2026";
10
- /** USER-only: sample CSV attached to a preset message; shown as a file row + client-side download. */
11
- export type UserCsvAttachment = {
10
+ /** USER-only: text file attached to a message; shown as downloadable file row(s). */
11
+ export type UserTextFileAttachment = {
12
12
  displayName: string;
13
13
  filename: string;
14
14
  content: string;
15
15
  };
16
- /** Send full text to the chat API while showing `displayText` + optional CSV attachment in the UI. */
16
+ /** Send full text to the chat API while showing `displayText` + optional file attachment(s) in the UI. */
17
17
  export type ChatSendMessagePayload = {
18
18
  apiMessage: string;
19
19
  displayText: string;
20
- userCsvAttachment?: UserCsvAttachment;
20
+ userTextFileAttachments?: UserTextFileAttachment[];
21
21
  };
22
22
  export interface Message {
23
23
  id: string;
24
24
  role: MessageRole;
25
25
  text: string;
26
26
  timestamp: number;
27
- userCsvAttachment?: UserCsvAttachment;
27
+ userTextFileAttachments?: UserTextFileAttachment[];
28
28
  }
29
29
  export interface Chat {
30
30
  session_id: string;
@@ -59,9 +59,9 @@ export type ScriptCompletePayload = {
59
59
  };
60
60
  export type ChatAttachmentDropItem = {
61
61
  file: File;
62
- /** UTF-8 text for native text files; PDF yields extracted text. */
62
+ /** UTF-8 text for native text files; PDF/DOCX/XLSX yield extracted text. */
63
63
  text: string;
64
- kind: 'text' | 'pdf';
64
+ kind: 'text' | 'pdf' | 'docx' | 'xlsx';
65
65
  };
66
66
  export interface ChatPromptProps {
67
67
  className?: string;
@@ -85,7 +85,7 @@ export interface ChatPromptProps {
85
85
  export interface ChatMessageProps {
86
86
  role: MessageRole;
87
87
  text: string;
88
- userCsvAttachment?: UserCsvAttachment;
88
+ userTextFileAttachments?: UserTextFileAttachment[];
89
89
  onQuickReply?: (branchKey: string, displayLabel: string) => void;
90
90
  /** Branch keys already taken (e.g. from chat history); hide quick-reply buttons for these. */
91
91
  suppressedQuickReplyKeys?: ReadonlySet<string>;
@@ -1,2 +1,2 @@
1
1
  import { type ChatMessageProps } from '../Chat.types';
2
- export declare function ChatMessage({ role, text, userCsvAttachment, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage, scriptContinue, onScriptContinue, renderMessageChart, }: ChatMessageProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ChatMessage({ role, text, userTextFileAttachments, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage, scriptContinue, onScriptContinue, renderMessageChart, }: ChatMessageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import type { UserTextFileAttachment } from '../Chat.types';
2
+ export declare function UserTextFileAttachmentBubble({ attachment, }: {
3
+ attachment: UserTextFileAttachment;
4
+ }): import("react/jsx-runtime").JSX.Element;
@@ -19,4 +19,4 @@ export interface ChatSheetProps extends Omit<UseChatPanelChromeModelInput, 'embe
19
19
  */
20
20
  inline?: boolean;
21
21
  }
22
- export declare function ChatSheet({ triggerLabel, triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, inline, }: ChatSheetProps): import("react/jsx-runtime").JSX.Element;
22
+ export declare function ChatSheet({ triggerLabel, triggerAriaLabel, actionsRef, renderTrigger, presets, scopeId, onMessage, onScriptComplete, onGenerateDashboard, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, inline, }: ChatSheetProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import type { ChatAttachmentDropItem, ChatSendMessagePayload, UserTextFileAttachment } from './Chat.types';
2
+ /** Resolve file attachments on a send payload. */
3
+ export declare function normalizeUserTextFileAttachments(payload: ChatSendMessagePayload): UserTextFileAttachment[];
4
+ /**
5
+ * Build `sendMessage` input from composer text and staged drop items.
6
+ * Returns a plain string when there are no attachments.
7
+ */
8
+ export declare function buildChatSendMessagePayload(displayText: string, attachments: readonly ChatAttachmentDropItem[]): string | ChatSendMessagePayload;
9
+ /** Display text from a string or structured send payload. */
10
+ export declare function displayTextFromSendPayload(message: string | ChatSendMessagePayload): string;
@@ -1,8 +1,10 @@
1
1
  /** MIME types and extensions accepted for chat text attachments. */
2
- export declare const TEXT_ATTACHMENT_ACCEPT_PARTS: readonly ["text/plain", ".txt", "text/csv", ".csv", "text/markdown", ".md", ".markdown", "application/json", ".json", "text/html", ".html", ".htm", "text/xml", "application/xml", ".xml", "text/yaml", "application/yaml", "application/x-yaml", ".yaml", ".yml", "text/tab-separated-values", ".tsv", "text/calendar", ".ics"];
2
+ export declare const TEXT_ATTACHMENT_ACCEPT_PARTS: readonly ["text/plain", ".txt", "text/csv", ".csv", "text/markdown", ".md", ".markdown", "application/json", ".json", "text/html", ".html", ".htm", "text/xml", "application/xml", ".xml", "text/yaml", "application/yaml", "application/x-yaml", ".yaml", ".yml", "text/tab-separated-values", ".tsv", "text/calendar", ".ics", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"];
3
3
  export declare const PDF_ATTACHMENT_ACCEPT_PARTS: readonly ["application/pdf", ".pdf"];
4
4
  /** Keep only tokens from `parts` that appear in the text attachment allowlist. */
5
5
  export declare function filterToTextAttachments(parts: readonly string[] | undefined): string[];
6
6
  export declare function buildAcceptAttr(filteredTextParts: readonly string[], allowPdf: boolean): string;
7
7
  export declare function isPdfFile(file: File): boolean;
8
+ export declare function isDocxFile(file: File): boolean;
9
+ export declare function isXlsxFile(file: File): boolean;
8
10
  export declare function isAttachmentsDropzoneEnabled(allowedAttachments: readonly string[] | undefined, allowPdfAttachments: boolean | undefined): boolean;
@@ -0,0 +1,2 @@
1
+ /** Best-effort plain text from DOCX via mammoth (loaded on demand). */
2
+ export declare function extractDocxFileToText(file: File): Promise<string>;
@@ -0,0 +1,2 @@
1
+ /** Best-effort plain text from XLSX; one CSV block per sheet (xlsx loaded on demand). */
2
+ export declare function extractXlsxFileToText(file: File): Promise<string>;
@@ -3,6 +3,8 @@ export { usedPresetIdsFromMessages } from './chat-preset-utils';
3
3
  export { ChatChrome } from './ChatChrome';
4
4
  export type { ChatChromeProps, ChatChromeResizeHandleConfig, } from './ChatChrome';
5
5
  export { TEXT_ATTACHMENT_ACCEPT_PARTS, filterToTextAttachments, } from './chatAttachmentAccept';
6
+ export { buildChatSendMessagePayload, displayTextFromSendPayload, normalizeUserTextFileAttachments, } from './buildChatSendMessagePayload';
7
+ export { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
6
8
  export { ChatSheet } from './ChatSheet/ChatSheet';
7
9
  export { useChatPanelChromeModel } from './ChatSheet/useChatPanelChromeModel';
8
10
  export type { ChatSheetActions, ChatSheetProps } from './ChatSheet/ChatSheet';
@@ -10,6 +12,6 @@ export type { UseChatPanelChromeModelInput, UseChatPanelChromeModelResult, } fro
10
12
  export { ChatMessage } from './ChatMessage';
11
13
  export { ChatPrompt } from './ChatPrompt';
12
14
  export { ChatPresets } from './ChatPresets';
13
- export type { Chat as ChatType, ChatAttachmentDropItem, ChatSendMessagePayload, ChatProps, ChatPreset as ChatPresetType, Message, UserCsvAttachment, } from './Chat.types';
15
+ export type { Chat as ChatType, ChatAttachmentDropItem, ChatSendMessagePayload, ChatProps, ChatPreset as ChatPresetType, Message, UserTextFileAttachment, } from './Chat.types';
14
16
  export { MessageRole } from './Chat.types';
15
17
  export { CsvIcon } from '../../icons/CsvIcon/CsvIcon';
@@ -0,0 +1,2 @@
1
+ /** Safe download filename from a display title or original file name. */
2
+ export declare function sanitizeAttachmentFilename(displayTitle: string, defaultExt?: string): string;
@@ -0,0 +1,3 @@
1
+ import type { Message, UserTextFileAttachment } from './Chat.types';
2
+ /** File attachments on a stored message. */
3
+ export declare function userTextFileAttachmentsFromMessage(message: Pick<Message, 'userTextFileAttachments'>): UserTextFileAttachment[];
@@ -1,10 +1,10 @@
1
1
  import { ReactNode } from 'react';
2
- import { type Chat, type ChatSendMessagePayload, MessageRole, type UserCsvAttachment } from '#uilib/components/ui/Chat/Chat.types';
2
+ import { type Chat, type ChatSendMessagePayload, MessageRole, type UserTextFileAttachment } from '#uilib/components/ui/Chat/Chat.types';
3
3
  import type { ChatResponse } from '#uilib/types/chat-api.types';
4
4
  export type SendChatMessageFn = (message: string, targetChatId: string) => Promise<ChatResponse>;
5
- export type { ChatSendMessagePayload, UserCsvAttachment, } from '#uilib/components/ui/Chat/Chat.types';
5
+ export type { ChatSendMessagePayload, UserTextFileAttachment, } from '#uilib/components/ui/Chat/Chat.types';
6
6
  export type AddChatMessageOptions = {
7
- userCsvAttachment?: UserCsvAttachment;
7
+ userTextFileAttachments?: UserTextFileAttachment[];
8
8
  };
9
9
  export interface ChatContextType {
10
10
  /** Returns the new session id, or undefined if no user / not created. */
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.12",
3
+ "version": "1.3.15",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -102,6 +102,7 @@
102
102
  "classnames": "^2.3.2",
103
103
  "lightweight-charts": "^5.0.9",
104
104
  "lucide-react": "^0.546.0",
105
+ "mammoth": "^1.9.0",
105
106
  "motion": "^12.23.12",
106
107
  "pdfjs-dist": "^4.10.38",
107
108
  "recharts": "^3.2.1",
@@ -109,7 +110,8 @@
109
110
  "style-inject": "^0.3.0",
110
111
  "tailwindcss": "^4.2.2",
111
112
  "tslib": "^2.8.1",
112
- "vaul": "^1.1.2"
113
+ "vaul": "^1.1.2",
114
+ "xlsx": "^0.18.5"
113
115
  },
114
116
  "peerDependencies": {
115
117
  "@auth0/auth0-react": "^2.3.1",