@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
@@ -0,0 +1,72 @@
1
+ import * as XLSX from 'xlsx';
2
+
3
+ import { extractXlsxFileToText } from './chatXlsxExtract';
4
+
5
+ function makeXlsxFile(
6
+ sheets: Record<string, (string | number)[][]>,
7
+ name = 'test.xlsx',
8
+ ): File {
9
+ const workbook = XLSX.utils.book_new();
10
+ for (const [sheetName, rows] of Object.entries(sheets)) {
11
+ XLSX.utils.book_append_sheet(
12
+ workbook,
13
+ XLSX.utils.aoa_to_sheet(rows),
14
+ sheetName,
15
+ );
16
+ }
17
+ const buffer = new Uint8Array(
18
+ XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }),
19
+ );
20
+ return new File([buffer], name, {
21
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
22
+ });
23
+ }
24
+
25
+ describe('extractXlsxFileToText', () => {
26
+ it('extracts non-empty sheets as CSV with headings', async () => {
27
+ const file = makeXlsxFile({
28
+ Data: [
29
+ ['name', 'value'],
30
+ ['a', 1],
31
+ ],
32
+ });
33
+
34
+ const text = await extractXlsxFileToText(file);
35
+ expect(text).toContain('## Sheet Data');
36
+ expect(text).toContain('name,value');
37
+ expect(text).toContain('a,1');
38
+ });
39
+
40
+ it('skips empty sheets', async () => {
41
+ const workbook = XLSX.utils.book_new();
42
+ XLSX.utils.book_append_sheet(
43
+ workbook,
44
+ XLSX.utils.aoa_to_sheet([]),
45
+ 'Empty',
46
+ );
47
+ XLSX.utils.book_append_sheet(
48
+ workbook,
49
+ XLSX.utils.aoa_to_sheet([['x']]),
50
+ 'Filled',
51
+ );
52
+ const buffer = new Uint8Array(
53
+ XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }),
54
+ );
55
+ const file = new File([buffer], 'mixed.xlsx', {
56
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
57
+ });
58
+
59
+ const text = await extractXlsxFileToText(file);
60
+ expect(text).not.toContain('## Sheet Empty');
61
+ expect(text).toContain('## Sheet Filled');
62
+ });
63
+
64
+ it('rejects files over the size limit', async () => {
65
+ const huge = new Uint8Array(10 * 1024 * 1024 + 1);
66
+ const file = new File([huge], 'huge.xlsx', {
67
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
68
+ });
69
+
70
+ await expect(extractXlsxFileToText(file)).rejects.toThrow(/too large/i);
71
+ });
72
+ });
@@ -0,0 +1,43 @@
1
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
2
+ const MAX_SHEETS = 20;
3
+ const MAX_CSV_CHARS_PER_SHEET = 500_000;
4
+
5
+ function truncateCsv(csv: string): string {
6
+ if (csv.length <= MAX_CSV_CHARS_PER_SHEET) return csv;
7
+ return `${csv.slice(0, MAX_CSV_CHARS_PER_SHEET).trimEnd()}…`;
8
+ }
9
+
10
+ /** Best-effort plain text from XLSX; one CSV block per sheet (xlsx loaded on demand). */
11
+ export async function extractXlsxFileToText(file: File): Promise<string> {
12
+ const buffer = new Uint8Array(await file.arrayBuffer());
13
+ if (buffer.byteLength > MAX_FILE_BYTES) {
14
+ throw new Error(
15
+ `${file.name} is too large (max ${MAX_FILE_BYTES / (1024 * 1024)} MB)`,
16
+ );
17
+ }
18
+
19
+ const XLSX = await import('xlsx');
20
+ const workbook = XLSX.read(buffer, { type: 'array' });
21
+ const sheetNames = workbook.SheetNames.slice(0, MAX_SHEETS);
22
+ const sheetTexts: string[] = [];
23
+
24
+ for (const sheetName of sheetNames) {
25
+ const sheet = workbook.Sheets[sheetName];
26
+ if (!sheet) continue;
27
+
28
+ const csv = truncateCsv(
29
+ XLSX.utils.sheet_to_csv(sheet, { blankrows: false }).trim(),
30
+ );
31
+ if (csv) {
32
+ sheetTexts.push(`## Sheet ${sheetName}\n\n${csv}`);
33
+ }
34
+ }
35
+
36
+ if (workbook.SheetNames.length > MAX_SHEETS) {
37
+ sheetTexts.push(
38
+ `_(Only the first ${MAX_SHEETS} of ${workbook.SheetNames.length} sheets were included.)_`,
39
+ );
40
+ }
41
+
42
+ return sheetTexts.join('\n\n');
43
+ }
@@ -9,6 +9,12 @@ export {
9
9
  TEXT_ATTACHMENT_ACCEPT_PARTS,
10
10
  filterToTextAttachments,
11
11
  } from './chatAttachmentAccept';
12
+ export {
13
+ buildChatSendMessagePayload,
14
+ displayTextFromSendPayload,
15
+ normalizeUserTextFileAttachments,
16
+ } from './buildChatSendMessagePayload';
17
+ export { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
12
18
  export { ChatSheet } from './ChatSheet/ChatSheet';
13
19
  export { useChatPanelChromeModel } from './ChatSheet/useChatPanelChromeModel';
14
20
  export type { ChatSheetActions, ChatSheetProps } from './ChatSheet/ChatSheet';
@@ -26,7 +32,7 @@ export type {
26
32
  ChatProps,
27
33
  ChatPreset as ChatPresetType,
28
34
  Message,
29
- UserCsvAttachment,
35
+ UserTextFileAttachment,
30
36
  } from './Chat.types';
31
37
  export { MessageRole } from './Chat.types';
32
38
  export { CsvIcon } from '../../icons/CsvIcon/CsvIcon';
@@ -0,0 +1,15 @@
1
+ /** Safe download filename from a display title or original file name. */
2
+ export function sanitizeAttachmentFilename(
3
+ displayTitle: string,
4
+ defaultExt = 'txt',
5
+ ): string {
6
+ const cleaned = displayTitle
7
+ .replace(/[/\\?%*:|"<>]/g, '-')
8
+ .replace(/\s+/g, ' ')
9
+ .trim()
10
+ .slice(0, 120);
11
+ const base = cleaned.length > 0 ? cleaned : 'attachment';
12
+ const ext = defaultExt.startsWith('.') ? defaultExt : `.${defaultExt}`;
13
+ const pattern = new RegExp(`${ext.replace('.', '\\.')}$`, 'i');
14
+ return pattern.test(base) ? base : `${base}${ext}`;
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { Message, UserTextFileAttachment } from './Chat.types';
2
+
3
+ /** File attachments on a stored message. */
4
+ export function userTextFileAttachmentsFromMessage(
5
+ message: Pick<Message, 'userTextFileAttachments'>,
6
+ ): UserTextFileAttachment[] {
7
+ return message.userTextFileAttachments ?? [];
8
+ }
@@ -13,8 +13,9 @@ import {
13
13
  type ChatSendMessagePayload,
14
14
  type Message,
15
15
  MessageRole,
16
- type UserCsvAttachment,
16
+ type UserTextFileAttachment,
17
17
  } from '#uilib/components/ui/Chat/Chat.types';
18
+ import { normalizeUserTextFileAttachments } from '#uilib/components/ui/Chat/buildChatSendMessagePayload';
18
19
  import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
19
20
  import type { ChatResponse } from '#uilib/types/chat-api.types';
20
21
  import { LS } from '@homecode/ui';
@@ -26,14 +27,14 @@ export type SendChatMessageFn = (
26
27
 
27
28
  export type {
28
29
  ChatSendMessagePayload,
29
- UserCsvAttachment,
30
+ UserTextFileAttachment,
30
31
  } from '#uilib/components/ui/Chat/Chat.types';
31
32
 
32
33
  const CHATS_PREFIX = 'chats-';
33
34
  const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
34
35
 
35
36
  export type AddChatMessageOptions = {
36
- userCsvAttachment?: UserCsvAttachment;
37
+ userTextFileAttachments?: UserTextFileAttachment[];
37
38
  };
38
39
 
39
40
  export interface ChatContextType {
@@ -300,14 +301,18 @@ export function ChatProvider({
300
301
  if (userSwitchKey === null) return undefined;
301
302
  addScopeIdToRegistry(scopeId);
302
303
  const storedText = stripJsonDashboardFences(text);
303
- const attachment =
304
- role === MessageRole.USER ? options?.userCsvAttachment : undefined;
304
+ const attachments =
305
+ role === MessageRole.USER
306
+ ? options?.userTextFileAttachments
307
+ : undefined;
305
308
  const newMessage: Message = {
306
309
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
307
310
  role,
308
311
  text: storedText,
309
312
  timestamp: Date.now(),
310
- ...(attachment ? { userCsvAttachment: attachment } : {}),
313
+ ...(attachments?.length
314
+ ? { userTextFileAttachments: attachments }
315
+ : {}),
311
316
  };
312
317
 
313
318
  setChats(prev => {
@@ -372,7 +377,7 @@ export function ChatProvider({
372
377
  MessageRole.USER,
373
378
  message.displayText,
374
379
  {
375
- userCsvAttachment: message.userCsvAttachment,
380
+ userTextFileAttachments: normalizeUserTextFileAttachments(message),
376
381
  },
377
382
  );
378
383
  }
@@ -97,16 +97,16 @@ export default function ChatAttachmentsDropzonePage() {
97
97
  <AppPageHeader
98
98
  breadcrumbs={[{ label: 'Chat' }, { label: 'Attachments dropzone' }]}
99
99
  title="Chat — attachments dropzone"
100
- subheader="Drop text files onto the chat shell; they appear on the prompt until you send."
100
+ subheader="Drop text, Office, or PDF files onto the chat shell; they appear on the prompt until you send."
101
101
  actions={<DocsHeaderActions />}
102
102
  />
103
103
  <PageContentSection>
104
104
  <p style={{ marginBottom: 16, fontSize: 14, lineHeight: 1.5 }}>
105
- Drop a <code>.txt</code>, <code>.csv</code>, <code>.md</code>, or{' '}
106
- <code>.pdf</code> file anywhere on the chat panel. The file shows
107
- above the composer; press send to post it. Only MIME types from the
108
- text allowlist are accepted; PDF parsing is optional via{' '}
109
- <code>allowPdfAttachments</code>.
105
+ Drop a <code>.txt</code>, <code>.csv</code>, <code>.md</code>,{' '}
106
+ <code>.docx</code>, <code>.xlsx</code>, or <code>.pdf</code> file
107
+ anywhere on the chat panel. The file shows above the composer; press
108
+ send to post it. Office and PDF parsers load on demand when you attach
109
+ those types; PDF also requires <code>allowPdfAttachments</code>.
110
110
  </p>
111
111
  <ChatChrome
112
112
  showResizeHandle={false}
@@ -133,25 +133,19 @@ export default function ChatAttachmentsDropzonePage() {
133
133
  effectiveScopeId="docs-chat-attachments-dropzone"
134
134
  onPromptSubmit={onPromptSubmit}
135
135
  onChatDeleted={() => {}}
136
- allowedAttachments={[
137
- 'text/plain',
138
- '.txt',
139
- 'text/csv',
140
- '.csv',
141
- 'text/markdown',
142
- '.md',
143
- 'application/json',
144
- '.json',
145
- ]}
136
+ allowedAttachments={TEXT_ATTACHMENT_ACCEPT_PARTS}
146
137
  allowPdfAttachments
147
138
  emptyState={{
148
- title: 'Drop a text file or PDF',
139
+ title: 'Drop a text, Office, or PDF file',
149
140
  description:
150
- 'Drag a file onto this panel, review it above the composer, then send.',
141
+ 'Drag a file onto this panel, review it above the composer, then send. DOCX and XLSX are parsed in the browser.',
151
142
  additionalContent: (
152
143
  <p style={{ fontSize: 13, opacity: 0.85 }}>
153
- Base allowlist includes{' '}
154
- {TEXT_ATTACHMENT_ACCEPT_PARTS.slice(0, 6).join(', ')}
144
+ Accepted types include <code>.txt</code>, <code>.csv</code>,{' '}
145
+ <code>.md</code>, <code>.json</code>, <code>.docx</code>,{' '}
146
+ <code>.xlsx</code>, and more (
147
+ {TEXT_ATTACHMENT_ACCEPT_PARTS.length} entries in the text
148
+ allowlist).
155
149
  </p>
156
150
  ),
157
151
  }}
@@ -42,11 +42,13 @@ function makeUserMessageWithCsv(
42
42
  role: MessageRole.USER,
43
43
  text: displayText,
44
44
  timestamp: Date.now(),
45
- userCsvAttachment: {
46
- displayName,
47
- filename: 'docs-sample.csv',
48
- content: SAMPLE_CSV,
49
- },
45
+ userTextFileAttachments: [
46
+ {
47
+ displayName,
48
+ filename: 'docs-sample.csv',
49
+ content: SAMPLE_CSV,
50
+ },
51
+ ],
50
52
  };
51
53
  }
52
54
 
@@ -1,10 +0,0 @@
1
- import { jsx } from 'react/jsx-runtime';
2
- import { FileChip } from '../../FileChip/FileChip.js';
3
- import { downloadTextFile } from '../../../../utils/downloadTextFile.js';
4
-
5
- const CSV_DOWNLOAD_HINT = 'Download .CSV file';
6
- function UserCsvAttachmentBubble({ attachment, }) {
7
- return (jsx(FileChip, { name: attachment.displayName, format: "csv", hint: CSV_DOWNLOAD_HINT, onClick: () => downloadTextFile(attachment.content, attachment.filename, 'text/csv;charset=utf-8') }));
8
- }
9
-
10
- export { UserCsvAttachmentBubble };
@@ -1,4 +0,0 @@
1
- import type { UserCsvAttachment } from '../Chat.types';
2
- export declare function UserCsvAttachmentBubble({ attachment, }: {
3
- attachment: UserCsvAttachment;
4
- }): import("react/jsx-runtime").JSX.Element;
@@ -1,27 +0,0 @@
1
- import { FileChip } from '#uilib/components/ui/FileChip';
2
- import { downloadTextFile } from '#uilib/utils/downloadTextFile';
3
-
4
- import type { UserCsvAttachment } from '../Chat.types';
5
-
6
- const CSV_DOWNLOAD_HINT = 'Download .CSV file';
7
-
8
- export function UserCsvAttachmentBubble({
9
- attachment,
10
- }: {
11
- attachment: UserCsvAttachment;
12
- }) {
13
- return (
14
- <FileChip
15
- name={attachment.displayName}
16
- format="csv"
17
- hint={CSV_DOWNLOAD_HINT}
18
- onClick={() =>
19
- downloadTextFile(
20
- attachment.content,
21
- attachment.filename,
22
- 'text/csv;charset=utf-8',
23
- )
24
- }
25
- />
26
- );
27
- }