@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.
- package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +4 -1
- package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +7 -3
- package/dist/esm/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.js +39 -0
- package/dist/esm/components/ui/Chat/ChatPrompt/ChatPromptAttachments.js +7 -1
- package/dist/esm/components/ui/Chat/ChatSheet/ChatSheet.js +4 -1
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +6 -3
- package/dist/esm/components/ui/Chat/buildChatSendMessagePayload.js +70 -0
- package/dist/esm/components/ui/Chat/chatAttachmentAccept.js +20 -1
- package/dist/esm/components/ui/Chat/chatAttachmentExtract.js +11 -1
- package/dist/esm/components/ui/Chat/chatDocxExtract.js +17 -0
- package/dist/esm/components/ui/Chat/chatXlsxExtract.js +34 -0
- package/dist/esm/components/ui/Chat/sanitizeAttachmentFilename.js +14 -0
- package/dist/esm/components/ui/Chat/userTextFileAttachments.js +6 -0
- package/dist/esm/contexts/chat-context.js +8 -3
- package/dist/esm/index.js +2 -0
- package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +8 -8
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/ChatMessage.d.ts +1 -1
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.d.ts +4 -0
- package/dist/esm/types/src/components/ui/Chat/ChatSheet/ChatSheet.d.ts +1 -1
- package/dist/esm/types/src/components/ui/Chat/buildChatSendMessagePayload.d.ts +10 -0
- package/dist/esm/types/src/components/ui/Chat/buildChatSendMessagePayload.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.d.ts +3 -1
- package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/chatDocxExtract.d.ts +2 -0
- package/dist/esm/types/src/components/ui/Chat/chatDocxExtract.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/chatXlsxExtract.d.ts +2 -0
- package/dist/esm/types/src/components/ui/Chat/chatXlsxExtract.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/index.d.ts +3 -1
- package/dist/esm/types/src/components/ui/Chat/sanitizeAttachmentFilename.d.ts +2 -0
- package/dist/esm/types/src/components/ui/Chat/userTextFileAttachments.d.ts +3 -0
- package/dist/esm/types/src/contexts/chat-context.d.ts +3 -3
- package/dist/esm/types/tests/setup.d.ts +1 -0
- package/package.json +4 -2
- package/src/components/ui/Chat/Chat.types.ts +8 -8
- package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +4 -1
- package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +12 -5
- package/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.tsx +50 -0
- package/src/components/ui/Chat/ChatPrompt/ChatPromptAttachments.tsx +9 -1
- package/src/components/ui/Chat/ChatSheet/ChatSheet.tsx +6 -0
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +10 -3
- package/src/components/ui/Chat/buildChatSendMessagePayload.test.ts +116 -0
- package/src/components/ui/Chat/buildChatSendMessagePayload.ts +82 -0
- package/src/components/ui/Chat/chatAttachmentAccept.test.ts +78 -0
- package/src/components/ui/Chat/chatAttachmentAccept.ts +25 -0
- package/src/components/ui/Chat/chatAttachmentExtract.ts +13 -1
- package/src/components/ui/Chat/chatDocxExtract.test.ts +40 -0
- package/src/components/ui/Chat/chatDocxExtract.ts +19 -0
- package/src/components/ui/Chat/chatXlsxExtract.test.ts +72 -0
- package/src/components/ui/Chat/chatXlsxExtract.ts +43 -0
- package/src/components/ui/Chat/index.ts +7 -1
- package/src/components/ui/Chat/sanitizeAttachmentFilename.ts +15 -0
- package/src/components/ui/Chat/userTextFileAttachments.ts +8 -0
- package/src/contexts/chat-context.tsx +12 -7
- package/src/docs/pages/ChatAttachmentsDropzonePage.tsx +14 -20
- package/src/docs/pages/ChatUserCsvAttachmentPage.tsx +7 -5
- package/dist/esm/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.js +0 -10
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.d.ts +0 -4
- 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:
|
|
15
|
-
export type
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
14
|
+
import { UserTextFileAttachmentBubble } from './UserTextFileAttachmentBubble';
|
|
14
15
|
|
|
15
16
|
export function ChatMessage({
|
|
16
17
|
role,
|
|
17
18
|
text,
|
|
18
|
-
|
|
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
|
-
{
|
|
57
|
-
<
|
|
58
|
-
|
|
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={
|
|
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
|
-
|
|
589
|
-
|
|
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
|
+
}
|