@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
@@ -11,18 +11,18 @@ export enum MessageRole {
11
11
  /** System placeholder while dashboard generation runs (must match ChatSheet `addMessage` text). */
12
12
  export const GENERATING_DASHBOARD_SYSTEM_TEXT = 'Generating dashboard…';
13
13
 
14
- /** USER-only: sample CSV attached to a preset message; shown as a file row + client-side download. */
15
- export type UserCsvAttachment = {
14
+ /** USER-only: text file attached to a message; shown as downloadable file row(s). */
15
+ export type UserTextFileAttachment = {
16
16
  displayName: string;
17
17
  filename: string;
18
18
  content: string;
19
19
  };
20
20
 
21
- /** Send full text to the chat API while showing `displayText` + optional CSV attachment in the UI. */
21
+ /** Send full text to the chat API while showing `displayText` + optional file attachment(s) in the UI. */
22
22
  export type ChatSendMessagePayload = {
23
23
  apiMessage: string;
24
24
  displayText: string;
25
- userCsvAttachment?: UserCsvAttachment;
25
+ userTextFileAttachments?: UserTextFileAttachment[];
26
26
  };
27
27
 
28
28
  export interface Message {
@@ -30,7 +30,7 @@ export interface Message {
30
30
  role: MessageRole;
31
31
  text: string;
32
32
  timestamp: number;
33
- userCsvAttachment?: UserCsvAttachment;
33
+ userTextFileAttachments?: UserTextFileAttachment[];
34
34
  }
35
35
 
36
36
  export interface Chat {
@@ -69,9 +69,9 @@ export type ScriptCompletePayload = {
69
69
 
70
70
  export type ChatAttachmentDropItem = {
71
71
  file: File;
72
- /** UTF-8 text for native text files; PDF yields extracted text. */
72
+ /** UTF-8 text for native text files; PDF/DOCX/XLSX yield extracted text. */
73
73
  text: string;
74
- kind: 'text' | 'pdf';
74
+ kind: 'text' | 'pdf' | 'docx' | 'xlsx';
75
75
  };
76
76
 
77
77
  export interface ChatPromptProps {
@@ -97,7 +97,7 @@ export interface ChatPromptProps {
97
97
  export interface ChatMessageProps {
98
98
  role: MessageRole;
99
99
  text: string;
100
- userCsvAttachment?: UserCsvAttachment;
100
+ userTextFileAttachments?: UserTextFileAttachment[];
101
101
  onQuickReply?: (branchKey: string, displayLabel: string) => void;
102
102
  /** Branch keys already taken (e.g. from chat history); hide quick-reply buttons for these. */
103
103
  suppressedQuickReplyKeys?: ReadonlySet<string>;
@@ -86,6 +86,9 @@ export function ChatChrome({
86
86
  setPendingAttachments(prev => [...prev, ...items]);
87
87
  }
88
88
  })
89
+ .catch(() => {
90
+ // Extraction failed (parse error, size limit, etc.); skip staging.
91
+ })
89
92
  .finally(() => setIsExtractingAttachments(false));
90
93
  },
91
94
  [allowPdfAttachments, promptBusy],
@@ -198,7 +201,7 @@ export function ChatChrome({
198
201
  key={msg.id}
199
202
  role={msg.role}
200
203
  text={msg.text}
201
- userCsvAttachment={msg.userCsvAttachment}
204
+ userTextFileAttachments={msg.userTextFileAttachments}
202
205
  onQuickReply={onQuickReply}
203
206
  suppressedQuickReplyKeys={suppressedQuickReplyKeys}
204
207
  quickReplyDisabled={isLoading}
@@ -8,14 +8,15 @@ import {
8
8
  GENERATING_DASHBOARD_SYSTEM_TEXT,
9
9
  MessageRole,
10
10
  } from '../Chat.types';
11
+ import { userTextFileAttachmentsFromMessage } from '../userTextFileAttachments';
11
12
  import { AgentMessageContent } from './AgentMessageContent';
12
13
  import S from './ChatMessage.styl';
13
- import { UserCsvAttachmentBubble } from './UserCsvAttachmentBubble';
14
+ import { UserTextFileAttachmentBubble } from './UserTextFileAttachmentBubble';
14
15
 
15
16
  export function ChatMessage({
16
17
  role,
17
18
  text,
18
- userCsvAttachment,
19
+ userTextFileAttachments,
19
20
  onQuickReply,
20
21
  suppressedQuickReplyKeys,
21
22
  quickReplyDisabled,
@@ -24,6 +25,9 @@ export function ChatMessage({
24
25
  onScriptContinue,
25
26
  renderMessageChart,
26
27
  }: ChatMessageProps) {
28
+ const fileAttachments = userTextFileAttachmentsFromMessage({
29
+ userTextFileAttachments,
30
+ });
27
31
  const isAssistant = role === MessageRole.ASSISTANT;
28
32
  const isSystem = role === MessageRole.SYSTEM;
29
33
 
@@ -53,9 +57,12 @@ export function ChatMessage({
53
57
  <div className={S.text}>
54
58
  <InteractiveContent text={text} />
55
59
  </div>
56
- {userCsvAttachment ? (
57
- <UserCsvAttachmentBubble attachment={userCsvAttachment} />
58
- ) : null}
60
+ {fileAttachments.map(attachment => (
61
+ <UserTextFileAttachmentBubble
62
+ key={`${attachment.displayName}:${attachment.filename}`}
63
+ attachment={attachment}
64
+ />
65
+ ))}
59
66
  </div>
60
67
  )}
61
68
  </div>
@@ -0,0 +1,50 @@
1
+ import { FileChip, type FileChipFormat } from '#uilib/components/ui/FileChip';
2
+ import { downloadTextFile } from '#uilib/utils/downloadTextFile';
3
+
4
+ import type { UserTextFileAttachment } from '../Chat.types';
5
+
6
+ function formatFromFilename(filename: string): FileChipFormat {
7
+ const lower = filename.toLowerCase();
8
+ if (lower.endsWith('.csv')) return 'csv';
9
+ if (lower.endsWith('.pdf')) return 'pdf';
10
+ if (lower.endsWith('.xlsx')) return 'text';
11
+ return 'text';
12
+ }
13
+
14
+ function mimeForFormat(format: FileChipFormat): string {
15
+ if (format === 'csv') return 'text/csv;charset=utf-8';
16
+ if (format === 'pdf') return 'application/pdf';
17
+ return 'text/plain;charset=utf-8';
18
+ }
19
+
20
+ function hintForFormat(format: FileChipFormat, filename: string): string {
21
+ const lower = filename.toLowerCase();
22
+ if (format === 'csv') return 'Download .CSV file';
23
+ if (format === 'pdf') return 'Download file';
24
+ if (lower.endsWith('.docx')) return 'Download Word document';
25
+ if (lower.endsWith('.xlsx')) return 'Download spreadsheet';
26
+ return 'Download text file';
27
+ }
28
+
29
+ export function UserTextFileAttachmentBubble({
30
+ attachment,
31
+ }: {
32
+ attachment: UserTextFileAttachment;
33
+ }) {
34
+ const format = formatFromFilename(attachment.filename);
35
+
36
+ return (
37
+ <FileChip
38
+ name={attachment.displayName}
39
+ format={format}
40
+ hint={hintForFormat(format, attachment.filename)}
41
+ onClick={() =>
42
+ downloadTextFile(
43
+ attachment.content,
44
+ attachment.filename,
45
+ mimeForFormat(format),
46
+ )
47
+ }
48
+ />
49
+ );
50
+ }
@@ -24,7 +24,15 @@ export function ChatPromptAttachments({
24
24
  className={S.attachmentItem}
25
25
  name={item.file.name}
26
26
  format={item.kind === 'pdf' ? 'pdf' : 'text'}
27
- hint={item.kind === 'pdf' ? 'PDF' : 'Text file'}
27
+ hint={
28
+ item.kind === 'pdf'
29
+ ? 'PDF'
30
+ : item.kind === 'docx'
31
+ ? 'Word document'
32
+ : item.kind === 'xlsx'
33
+ ? 'Spreadsheet'
34
+ : 'Text file'
35
+ }
28
36
  onRemove={() => onRemove(index)}
29
37
  disabled={disabled}
30
38
  />
@@ -44,6 +44,9 @@ export function ChatSheet({
44
44
  onGenerateDashboard,
45
45
  renderMessageChart,
46
46
  emptyState,
47
+ allowedAttachments,
48
+ allowPdfAttachments,
49
+ onAttachmentsDropped,
47
50
  inline = false,
48
51
  }: ChatSheetProps) {
49
52
  const model = useChatPanelChromeModel({
@@ -55,6 +58,9 @@ export function ChatSheet({
55
58
  onGenerateDashboard,
56
59
  renderMessageChart,
57
60
  emptyState,
61
+ allowedAttachments,
62
+ allowPdfAttachments,
63
+ onAttachmentsDropped,
58
64
  });
59
65
 
60
66
  if (actionsRef) {
@@ -19,6 +19,10 @@ import {
19
19
  textHasQuickReplyMarkers,
20
20
  } from '#uilib/components/ui/Chat/ChatMessage/presetScript';
21
21
  import type { ChatPresetsLayout } from '#uilib/components/ui/Chat/ChatPresets';
22
+ import {
23
+ buildChatSendMessagePayload,
24
+ displayTextFromSendPayload,
25
+ } from '#uilib/components/ui/Chat/buildChatSendMessagePayload';
22
26
  import {
23
27
  formatChatTranscript,
24
28
  usedPresetIdsFromMessages,
@@ -462,10 +466,12 @@ export function useChatPanelChromeModel({
462
466
  );
463
467
 
464
468
  const handlePromptSubmit = useCallback(
465
- async (message: string) => {
469
+ async (message: string, attachments?: ChatAttachmentDropItem[]) => {
466
470
  const chatId = currentChatId;
467
471
  if (!chatId) return;
468
472
 
473
+ const stagedAttachments = attachments ?? [];
474
+
469
475
  const quickBranches = quickReplyBranchesByChat[chatId];
470
476
  const graphActive = Boolean(
471
477
  quickBranches && Object.keys(quickBranches).length > 0,
@@ -585,8 +591,9 @@ export function useChatPanelChromeModel({
585
591
 
586
592
  try {
587
593
  if (chatId) endLocalDemoFlow(chatId);
588
- await sendMessage(message);
589
- onMessage?.(message);
594
+ const payload = buildChatSendMessagePayload(message, stagedAttachments);
595
+ await sendMessage(payload);
596
+ onMessage?.(displayTextFromSendPayload(payload));
590
597
  } catch (error) {
591
598
  logger.error('Error sending chat message:', error);
592
599
  }
@@ -0,0 +1,116 @@
1
+ import type { ChatAttachmentDropItem } from './Chat.types';
2
+ import {
3
+ buildChatSendMessagePayload,
4
+ displayTextFromSendPayload,
5
+ normalizeUserTextFileAttachments,
6
+ } from './buildChatSendMessagePayload';
7
+
8
+ function makeDropItem(
9
+ name: string,
10
+ text: string,
11
+ kind: ChatAttachmentDropItem['kind'] = 'text',
12
+ ): ChatAttachmentDropItem {
13
+ return {
14
+ file: { name } as File,
15
+ text,
16
+ kind,
17
+ };
18
+ }
19
+
20
+ describe('buildChatSendMessagePayload', () => {
21
+ it('returns trimmed string when there are no attachments', () => {
22
+ expect(buildChatSendMessagePayload(' hello ', [])).toBe('hello');
23
+ });
24
+
25
+ it('builds payload with user text and full file bodies in apiMessage', () => {
26
+ const result = buildChatSendMessagePayload('Question', [
27
+ makeDropItem('data.csv', 'a,b\n1,2'),
28
+ ]);
29
+ expect(typeof result).toBe('object');
30
+ if (typeof result === 'string') {
31
+ throw new Error('expected payload object');
32
+ }
33
+ expect(result.displayText).toBe('Question');
34
+ expect(result.apiMessage).toBe('Question\n\na,b\n1,2');
35
+ expect(result.userTextFileAttachments).toHaveLength(1);
36
+ expect(result.userTextFileAttachments?.[0].displayName).toBe('data.csv');
37
+ expect(result.userTextFileAttachments?.[0].content).toBe('a,b\n1,2');
38
+ expect(result.userTextFileAttachments?.[0].filename).toMatch(/\.csv$/i);
39
+ });
40
+
41
+ it('uses first file name as displayText when message is empty', () => {
42
+ const result = buildChatSendMessagePayload('', [
43
+ makeDropItem('report.pdf', 'pdf text', 'pdf'),
44
+ ]);
45
+ if (typeof result === 'string') {
46
+ throw new Error('expected payload object');
47
+ }
48
+ expect(result.displayText).toBe('report.pdf');
49
+ expect(result.apiMessage).toBe('report.pdf\n\npdf text');
50
+ expect(result.userTextFileAttachments?.[0].filename).toMatch(/\.pdf$/i);
51
+ });
52
+
53
+ it('uses docx and xlsx extensions from kind', () => {
54
+ const docx = buildChatSendMessagePayload('', [
55
+ makeDropItem('brief.docx', 'word body', 'docx'),
56
+ ]);
57
+ const xlsx = buildChatSendMessagePayload('', [
58
+ makeDropItem('sheet.xlsx', 'a,b', 'xlsx'),
59
+ ]);
60
+ if (typeof docx === 'string' || typeof xlsx === 'string') {
61
+ throw new Error('expected payload object');
62
+ }
63
+ expect(docx.userTextFileAttachments?.[0].filename).toMatch(/\.docx$/i);
64
+ expect(xlsx.userTextFileAttachments?.[0].filename).toMatch(/\.xlsx$/i);
65
+ });
66
+
67
+ it('maps multiple attachments', () => {
68
+ const result = buildChatSendMessagePayload('Hi', [
69
+ makeDropItem('one.txt', 'first'),
70
+ makeDropItem('two.txt', 'second'),
71
+ ]);
72
+ if (typeof result === 'string') {
73
+ throw new Error('expected payload object');
74
+ }
75
+ expect(result.userTextFileAttachments).toHaveLength(2);
76
+ expect(result.apiMessage).toBe('Hi\n\nfirst\n\nsecond');
77
+ });
78
+ });
79
+
80
+ describe('normalizeUserTextFileAttachments', () => {
81
+ it('returns userTextFileAttachments or empty array', () => {
82
+ const attachment = {
83
+ displayName: 'data.csv',
84
+ filename: 'data.csv',
85
+ content: 'x',
86
+ };
87
+ expect(
88
+ normalizeUserTextFileAttachments({
89
+ apiMessage: 'a',
90
+ displayText: 'b',
91
+ userTextFileAttachments: [attachment],
92
+ }),
93
+ ).toEqual([attachment]);
94
+ expect(
95
+ normalizeUserTextFileAttachments({
96
+ apiMessage: 'a',
97
+ displayText: 'b',
98
+ }),
99
+ ).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe('displayTextFromSendPayload', () => {
104
+ it('returns string as-is', () => {
105
+ expect(displayTextFromSendPayload('plain')).toBe('plain');
106
+ });
107
+
108
+ it('returns displayText from payload', () => {
109
+ expect(
110
+ displayTextFromSendPayload({
111
+ apiMessage: 'full',
112
+ displayText: 'short',
113
+ }),
114
+ ).toBe('short');
115
+ });
116
+ });
@@ -0,0 +1,82 @@
1
+ import type {
2
+ ChatAttachmentDropItem,
3
+ ChatSendMessagePayload,
4
+ UserTextFileAttachment,
5
+ } from './Chat.types';
6
+ import { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
7
+
8
+ function defaultExtForAttachment(item: ChatAttachmentDropItem): string {
9
+ const name = item.file.name.toLowerCase();
10
+ if (item.kind === 'pdf' || name.endsWith('.pdf')) return 'pdf';
11
+ if (item.kind === 'docx' || name.endsWith('.docx')) return 'docx';
12
+ if (item.kind === 'xlsx' || name.endsWith('.xlsx')) return 'xlsx';
13
+ if (name.endsWith('.csv')) return 'csv';
14
+ if (name.endsWith('.json')) return 'json';
15
+ if (name.endsWith('.md') || name.endsWith('.markdown')) return 'md';
16
+ if (name.endsWith('.html') || name.endsWith('.htm')) return 'html';
17
+ if (name.endsWith('.xml')) return 'xml';
18
+ if (name.endsWith('.yaml') || name.endsWith('.yml')) return 'yaml';
19
+ if (name.endsWith('.tsv')) return 'tsv';
20
+ if (name.endsWith('.ics')) return 'ics';
21
+ return 'txt';
22
+ }
23
+
24
+ function dropItemToUserAttachment(
25
+ item: ChatAttachmentDropItem,
26
+ ): UserTextFileAttachment {
27
+ const ext = defaultExtForAttachment(item);
28
+ return {
29
+ displayName: item.file.name,
30
+ filename: sanitizeAttachmentFilename(item.file.name, ext),
31
+ content: item.text,
32
+ };
33
+ }
34
+
35
+ function buildApiMessage(
36
+ displayText: string,
37
+ attachments: readonly ChatAttachmentDropItem[],
38
+ ): string {
39
+ const parts = [
40
+ displayText,
41
+ ...attachments.map(item => item.text.trim()).filter(Boolean),
42
+ ].filter(Boolean);
43
+ return parts.join('\n\n');
44
+ }
45
+
46
+ /** Resolve file attachments on a send payload. */
47
+ export function normalizeUserTextFileAttachments(
48
+ payload: ChatSendMessagePayload,
49
+ ): UserTextFileAttachment[] {
50
+ return payload.userTextFileAttachments ?? [];
51
+ }
52
+
53
+ /**
54
+ * Build `sendMessage` input from composer text and staged drop items.
55
+ * Returns a plain string when there are no attachments.
56
+ */
57
+ export function buildChatSendMessagePayload(
58
+ displayText: string,
59
+ attachments: readonly ChatAttachmentDropItem[],
60
+ ): string | ChatSendMessagePayload {
61
+ const trimmed = displayText.trim();
62
+ if (attachments.length === 0) {
63
+ return trimmed;
64
+ }
65
+
66
+ const userTextFileAttachments = attachments.map(dropItemToUserAttachment);
67
+ const resolvedDisplayText =
68
+ trimmed.length > 0 ? trimmed : userTextFileAttachments[0].displayName;
69
+
70
+ return {
71
+ apiMessage: buildApiMessage(resolvedDisplayText, attachments),
72
+ displayText: resolvedDisplayText,
73
+ userTextFileAttachments,
74
+ };
75
+ }
76
+
77
+ /** Display text from a string or structured send payload. */
78
+ export function displayTextFromSendPayload(
79
+ message: string | ChatSendMessagePayload,
80
+ ): string {
81
+ return typeof message === 'string' ? message : message.displayText;
82
+ }
@@ -0,0 +1,78 @@
1
+ import {
2
+ TEXT_ATTACHMENT_ACCEPT_PARTS,
3
+ buildAcceptAttr,
4
+ filterToTextAttachments,
5
+ isDocxFile,
6
+ isPdfFile,
7
+ isXlsxFile,
8
+ } from './chatAttachmentAccept';
9
+
10
+ function makeFile(name: string, type = ''): File {
11
+ return { name, type } as File;
12
+ }
13
+
14
+ describe('TEXT_ATTACHMENT_ACCEPT_PARTS', () => {
15
+ it('includes docx and xlsx MIME types and extensions', () => {
16
+ expect(TEXT_ATTACHMENT_ACCEPT_PARTS).toContain('.docx');
17
+ expect(TEXT_ATTACHMENT_ACCEPT_PARTS).toContain('.xlsx');
18
+ expect(TEXT_ATTACHMENT_ACCEPT_PARTS).toContain(
19
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
20
+ );
21
+ expect(TEXT_ATTACHMENT_ACCEPT_PARTS).toContain(
22
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
23
+ );
24
+ });
25
+ });
26
+
27
+ describe('isDocxFile', () => {
28
+ it('detects by extension and MIME type', () => {
29
+ expect(
30
+ isDocxFile(
31
+ makeFile(
32
+ 'notes.docx',
33
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
34
+ ),
35
+ ),
36
+ ).toBe(true);
37
+ expect(isDocxFile(makeFile('notes.docx'))).toBe(true);
38
+ expect(isDocxFile(makeFile('notes.txt'))).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe('isXlsxFile', () => {
43
+ it('detects by extension and MIME type', () => {
44
+ expect(
45
+ isXlsxFile(
46
+ makeFile(
47
+ 'data.xlsx',
48
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
49
+ ),
50
+ ),
51
+ ).toBe(true);
52
+ expect(isXlsxFile(makeFile('data.xlsx'))).toBe(true);
53
+ expect(isXlsxFile(makeFile('data.csv'))).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe('isPdfFile', () => {
58
+ it('does not treat docx or xlsx as pdf', () => {
59
+ expect(isPdfFile(makeFile('file.docx'))).toBe(false);
60
+ expect(isPdfFile(makeFile('file.xlsx'))).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe('filterToTextAttachments', () => {
65
+ it('keeps docx and xlsx tokens from the allowlist', () => {
66
+ expect(
67
+ filterToTextAttachments(['.docx', '.xlsx', 'application/pdf']),
68
+ ).toEqual(['.docx', '.xlsx']);
69
+ });
70
+ });
71
+
72
+ describe('buildAcceptAttr', () => {
73
+ it('includes filtered text parts and optional pdf', () => {
74
+ expect(buildAcceptAttr(['.docx', '.txt'], false)).toBe('.docx,.txt');
75
+ expect(buildAcceptAttr(['.docx'], true)).toContain('.docx');
76
+ expect(buildAcceptAttr(['.docx'], true)).toContain('.pdf');
77
+ });
78
+ });
@@ -24,6 +24,10 @@ export 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
  ] as const;
28
32
 
29
33
  export const PDF_ATTACHMENT_ACCEPT_PARTS = ['application/pdf', '.pdf'] as const;
@@ -59,6 +63,27 @@ export function isPdfFile(file: File): boolean {
59
63
  return file.name.toLowerCase().endsWith('.pdf');
60
64
  }
61
65
 
66
+ export function isDocxFile(file: File): boolean {
67
+ const type = file.type.toLowerCase();
68
+ if (
69
+ type ===
70
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
71
+ ) {
72
+ return true;
73
+ }
74
+ return file.name.toLowerCase().endsWith('.docx');
75
+ }
76
+
77
+ export function isXlsxFile(file: File): boolean {
78
+ const type = file.type.toLowerCase();
79
+ if (
80
+ type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
81
+ ) {
82
+ return true;
83
+ }
84
+ return file.name.toLowerCase().endsWith('.xlsx');
85
+ }
86
+
62
87
  export function isAttachmentsDropzoneEnabled(
63
88
  allowedAttachments: readonly string[] | undefined,
64
89
  allowPdfAttachments: boolean | undefined,
@@ -1,6 +1,8 @@
1
1
  import type { ChatAttachmentDropItem } from './Chat.types';
2
- import { isPdfFile } from './chatAttachmentAccept';
2
+ import { isDocxFile, isPdfFile, isXlsxFile } from './chatAttachmentAccept';
3
+ import { extractDocxFileToText } from './chatDocxExtract';
3
4
  import { extractPdfFileToText } from './chatPdfExtract';
5
+ import { extractXlsxFileToText } from './chatXlsxExtract';
4
6
 
5
7
  function readTextFile(file: File): Promise<string> {
6
8
  return new Promise((resolve, reject) => {
@@ -24,6 +26,16 @@ export async function extractChatAttachmentItems(
24
26
  return { file, text, kind: 'pdf' as const };
25
27
  }
26
28
 
29
+ if (isDocxFile(file)) {
30
+ const text = await extractDocxFileToText(file);
31
+ return { file, text, kind: 'docx' as const };
32
+ }
33
+
34
+ if (isXlsxFile(file)) {
35
+ const text = await extractXlsxFileToText(file);
36
+ return { file, text, kind: 'xlsx' as const };
37
+ }
38
+
27
39
  const text = await readTextFile(file);
28
40
  return { file, text, kind: 'text' as const };
29
41
  }),
@@ -0,0 +1,40 @@
1
+ import { extractDocxFileToText } from './chatDocxExtract';
2
+
3
+ const extractRawText = jest.fn();
4
+
5
+ jest.mock('mammoth', () => ({
6
+ extractRawText: (...args: unknown[]) => extractRawText(...args),
7
+ }));
8
+
9
+ describe('extractDocxFileToText', () => {
10
+ beforeEach(() => {
11
+ extractRawText.mockReset();
12
+ });
13
+
14
+ it('returns trimmed text from mammoth', async () => {
15
+ extractRawText.mockResolvedValue({
16
+ value: ' Hello from Word ',
17
+ messages: [],
18
+ });
19
+
20
+ const file = new File([new Uint8Array(8)], 'doc.docx', {
21
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
22
+ });
23
+
24
+ await expect(extractDocxFileToText(file)).resolves.toBe('Hello from Word');
25
+ expect(extractRawText).toHaveBeenCalledTimes(1);
26
+ });
27
+
28
+ it('throws when mammoth reports errors', async () => {
29
+ extractRawText.mockResolvedValue({
30
+ value: '',
31
+ messages: [{ type: 'error', message: 'corrupt file' }],
32
+ });
33
+
34
+ const file = new File([new Uint8Array(8)], 'bad.docx', {
35
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
36
+ });
37
+
38
+ await expect(extractDocxFileToText(file)).rejects.toThrow(/corrupt file/i);
39
+ });
40
+ });
@@ -0,0 +1,19 @@
1
+ /** Best-effort plain text from DOCX via mammoth (loaded on demand). */
2
+ export async function extractDocxFileToText(file: File): Promise<string> {
3
+ const mammoth = await import('mammoth');
4
+ const result = await mammoth.extractRawText({
5
+ arrayBuffer: await file.arrayBuffer(),
6
+ });
7
+
8
+ const errors = result.messages.filter(m => m.type === 'error');
9
+ if (errors.length > 0) {
10
+ const detail = errors.map(m => m.message).join('; ');
11
+ throw new Error(
12
+ detail
13
+ ? `Failed to read ${file.name}: ${detail}`
14
+ : `Failed to read ${file.name}`,
15
+ );
16
+ }
17
+
18
+ return result.value.trim();
19
+ }