@sybilion/uilib 1.3.12 → 1.3.14
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 +1 -1
- package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +7 -3
- package/dist/esm/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.js +32 -0
- 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 +66 -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 +6 -6
- 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/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/package.json +1 -1
- package/src/components/ui/Chat/Chat.types.ts +6 -6
- package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +1 -1
- package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +12 -5
- package/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.tsx +46 -0
- 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 +102 -0
- package/src/components/ui/Chat/buildChatSendMessagePayload.ts +80 -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/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
|
@@ -65,7 +65,7 @@ function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPr
|
|
|
65
65
|
}, [isEmpty, messages.length]);
|
|
66
66
|
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
67
|
const isLast = index === arr.length - 1;
|
|
68
|
-
return (jsx(Chat.Message, { role: msg.role, text: msg.text,
|
|
68
|
+
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
69
|
? { label: scriptContinueLabel }
|
|
70
70
|
: undefined, onScriptContinue: isLast && scriptContinueLabel
|
|
71
71
|
? 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 {
|
|
9
|
+
import { UserTextFileAttachmentBubble } from './UserTextFileAttachmentBubble.js';
|
|
9
10
|
|
|
10
|
-
function ChatMessage({ role, text,
|
|
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 }) }),
|
|
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,32 @@
|
|
|
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
|
+
return 'text';
|
|
12
|
+
}
|
|
13
|
+
function mimeForFormat(format) {
|
|
14
|
+
if (format === 'csv')
|
|
15
|
+
return 'text/csv;charset=utf-8';
|
|
16
|
+
if (format === 'pdf')
|
|
17
|
+
return 'application/pdf';
|
|
18
|
+
return 'text/plain;charset=utf-8';
|
|
19
|
+
}
|
|
20
|
+
function hintForFormat(format) {
|
|
21
|
+
if (format === 'csv')
|
|
22
|
+
return 'Download .CSV file';
|
|
23
|
+
if (format === 'pdf')
|
|
24
|
+
return 'Download file';
|
|
25
|
+
return 'Download text file';
|
|
26
|
+
}
|
|
27
|
+
function UserTextFileAttachmentBubble({ attachment, }) {
|
|
28
|
+
const format = formatFromFilename(attachment.filename);
|
|
29
|
+
return (jsx(FileChip, { name: attachment.displayName, format: format, hint: hintForFormat(format), onClick: () => downloadTextFile(attachment.content, attachment.filename, mimeForFormat(format)) }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { UserTextFileAttachmentBubble };
|
|
@@ -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
|
-
|
|
406
|
-
|
|
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,66 @@
|
|
|
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 (name.endsWith('.csv'))
|
|
8
|
+
return 'csv';
|
|
9
|
+
if (name.endsWith('.json'))
|
|
10
|
+
return 'json';
|
|
11
|
+
if (name.endsWith('.md') || name.endsWith('.markdown'))
|
|
12
|
+
return 'md';
|
|
13
|
+
if (name.endsWith('.html') || name.endsWith('.htm'))
|
|
14
|
+
return 'html';
|
|
15
|
+
if (name.endsWith('.xml'))
|
|
16
|
+
return 'xml';
|
|
17
|
+
if (name.endsWith('.yaml') || name.endsWith('.yml'))
|
|
18
|
+
return 'yaml';
|
|
19
|
+
if (name.endsWith('.tsv'))
|
|
20
|
+
return 'tsv';
|
|
21
|
+
if (name.endsWith('.ics'))
|
|
22
|
+
return 'ics';
|
|
23
|
+
return 'txt';
|
|
24
|
+
}
|
|
25
|
+
function dropItemToUserAttachment(item) {
|
|
26
|
+
const ext = defaultExtForAttachment(item);
|
|
27
|
+
return {
|
|
28
|
+
displayName: item.file.name,
|
|
29
|
+
filename: sanitizeAttachmentFilename(item.file.name, ext),
|
|
30
|
+
content: item.text,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function buildApiMessage(displayText, attachments) {
|
|
34
|
+
const parts = [
|
|
35
|
+
displayText,
|
|
36
|
+
...attachments.map(item => item.text.trim()).filter(Boolean),
|
|
37
|
+
].filter(Boolean);
|
|
38
|
+
return parts.join('\n\n');
|
|
39
|
+
}
|
|
40
|
+
/** Resolve file attachments on a send payload. */
|
|
41
|
+
function normalizeUserTextFileAttachments(payload) {
|
|
42
|
+
return payload.userTextFileAttachments ?? [];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build `sendMessage` input from composer text and staged drop items.
|
|
46
|
+
* Returns a plain string when there are no attachments.
|
|
47
|
+
*/
|
|
48
|
+
function buildChatSendMessagePayload(displayText, attachments) {
|
|
49
|
+
const trimmed = displayText.trim();
|
|
50
|
+
if (attachments.length === 0) {
|
|
51
|
+
return trimmed;
|
|
52
|
+
}
|
|
53
|
+
const userTextFileAttachments = attachments.map(dropItemToUserAttachment);
|
|
54
|
+
const resolvedDisplayText = trimmed.length > 0 ? trimmed : userTextFileAttachments[0].displayName;
|
|
55
|
+
return {
|
|
56
|
+
apiMessage: buildApiMessage(resolvedDisplayText, attachments),
|
|
57
|
+
displayText: resolvedDisplayText,
|
|
58
|
+
userTextFileAttachments,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Display text from a string or structured send payload. */
|
|
62
|
+
function displayTextFromSendPayload(message) {
|
|
63
|
+
return typeof message === 'string' ? message : message.displayText;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { buildChatSendMessagePayload, displayTextFromSendPayload, normalizeUserTextFileAttachments };
|
|
@@ -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 };
|
|
@@ -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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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:
|
|
11
|
-
export type
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
userTextFileAttachments?: UserTextFileAttachment[];
|
|
28
28
|
}
|
|
29
29
|
export interface Chat {
|
|
30
30
|
session_id: string;
|
|
@@ -85,7 +85,7 @@ export interface ChatPromptProps {
|
|
|
85
85
|
export interface ChatMessageProps {
|
|
86
86
|
role: MessageRole;
|
|
87
87
|
text: string;
|
|
88
|
-
|
|
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,
|
|
2
|
+
export declare function ChatMessage({ role, text, userTextFileAttachments, onQuickReply, suppressedQuickReplyKeys, quickReplyDisabled, isLastMessage, scriptContinue, onScriptContinue, renderMessageChart, }: ChatMessageProps): 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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,
|
|
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';
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
|
-
import { type Chat, type ChatSendMessagePayload, MessageRole, type
|
|
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,
|
|
5
|
+
export type { ChatSendMessagePayload, UserTextFileAttachment, } from '#uilib/components/ui/Chat/Chat.types';
|
|
6
6
|
export type AddChatMessageOptions = {
|
|
7
|
-
|
|
7
|
+
userTextFileAttachments?: UserTextFileAttachment[];
|
|
8
8
|
};
|
|
9
9
|
export interface ChatContextType {
|
|
10
10
|
/** Returns the new session id, or undefined if no user / not created. */
|
package/package.json
CHANGED
|
@@ -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 {
|
|
@@ -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>;
|
|
@@ -198,7 +198,7 @@ export function ChatChrome({
|
|
|
198
198
|
key={msg.id}
|
|
199
199
|
role={msg.role}
|
|
200
200
|
text={msg.text}
|
|
201
|
-
|
|
201
|
+
userTextFileAttachments={msg.userTextFileAttachments}
|
|
202
202
|
onQuickReply={onQuickReply}
|
|
203
203
|
suppressedQuickReplyKeys={suppressedQuickReplyKeys}
|
|
204
204
|
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,46 @@
|
|
|
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
|
+
return 'text';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mimeForFormat(format: FileChipFormat): string {
|
|
14
|
+
if (format === 'csv') return 'text/csv;charset=utf-8';
|
|
15
|
+
if (format === 'pdf') return 'application/pdf';
|
|
16
|
+
return 'text/plain;charset=utf-8';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hintForFormat(format: FileChipFormat): string {
|
|
20
|
+
if (format === 'csv') return 'Download .CSV file';
|
|
21
|
+
if (format === 'pdf') return 'Download file';
|
|
22
|
+
return 'Download text file';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function UserTextFileAttachmentBubble({
|
|
26
|
+
attachment,
|
|
27
|
+
}: {
|
|
28
|
+
attachment: UserTextFileAttachment;
|
|
29
|
+
}) {
|
|
30
|
+
const format = formatFromFilename(attachment.filename);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<FileChip
|
|
34
|
+
name={attachment.displayName}
|
|
35
|
+
format={format}
|
|
36
|
+
hint={hintForFormat(format)}
|
|
37
|
+
onClick={() =>
|
|
38
|
+
downloadTextFile(
|
|
39
|
+
attachment.content,
|
|
40
|
+
attachment.filename,
|
|
41
|
+
mimeForFormat(format),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -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,102 @@
|
|
|
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: 'text' | 'pdf' = '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('maps multiple attachments', () => {
|
|
54
|
+
const result = buildChatSendMessagePayload('Hi', [
|
|
55
|
+
makeDropItem('one.txt', 'first'),
|
|
56
|
+
makeDropItem('two.txt', 'second'),
|
|
57
|
+
]);
|
|
58
|
+
if (typeof result === 'string') {
|
|
59
|
+
throw new Error('expected payload object');
|
|
60
|
+
}
|
|
61
|
+
expect(result.userTextFileAttachments).toHaveLength(2);
|
|
62
|
+
expect(result.apiMessage).toBe('Hi\n\nfirst\n\nsecond');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('normalizeUserTextFileAttachments', () => {
|
|
67
|
+
it('returns userTextFileAttachments or empty array', () => {
|
|
68
|
+
const attachment = {
|
|
69
|
+
displayName: 'data.csv',
|
|
70
|
+
filename: 'data.csv',
|
|
71
|
+
content: 'x',
|
|
72
|
+
};
|
|
73
|
+
expect(
|
|
74
|
+
normalizeUserTextFileAttachments({
|
|
75
|
+
apiMessage: 'a',
|
|
76
|
+
displayText: 'b',
|
|
77
|
+
userTextFileAttachments: [attachment],
|
|
78
|
+
}),
|
|
79
|
+
).toEqual([attachment]);
|
|
80
|
+
expect(
|
|
81
|
+
normalizeUserTextFileAttachments({
|
|
82
|
+
apiMessage: 'a',
|
|
83
|
+
displayText: 'b',
|
|
84
|
+
}),
|
|
85
|
+
).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('displayTextFromSendPayload', () => {
|
|
90
|
+
it('returns string as-is', () => {
|
|
91
|
+
expect(displayTextFromSendPayload('plain')).toBe('plain');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns displayText from payload', () => {
|
|
95
|
+
expect(
|
|
96
|
+
displayTextFromSendPayload({
|
|
97
|
+
apiMessage: 'full',
|
|
98
|
+
displayText: 'short',
|
|
99
|
+
}),
|
|
100
|
+
).toBe('short');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
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 (name.endsWith('.csv')) return 'csv';
|
|
12
|
+
if (name.endsWith('.json')) return 'json';
|
|
13
|
+
if (name.endsWith('.md') || name.endsWith('.markdown')) return 'md';
|
|
14
|
+
if (name.endsWith('.html') || name.endsWith('.htm')) return 'html';
|
|
15
|
+
if (name.endsWith('.xml')) return 'xml';
|
|
16
|
+
if (name.endsWith('.yaml') || name.endsWith('.yml')) return 'yaml';
|
|
17
|
+
if (name.endsWith('.tsv')) return 'tsv';
|
|
18
|
+
if (name.endsWith('.ics')) return 'ics';
|
|
19
|
+
return 'txt';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function dropItemToUserAttachment(
|
|
23
|
+
item: ChatAttachmentDropItem,
|
|
24
|
+
): UserTextFileAttachment {
|
|
25
|
+
const ext = defaultExtForAttachment(item);
|
|
26
|
+
return {
|
|
27
|
+
displayName: item.file.name,
|
|
28
|
+
filename: sanitizeAttachmentFilename(item.file.name, ext),
|
|
29
|
+
content: item.text,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildApiMessage(
|
|
34
|
+
displayText: string,
|
|
35
|
+
attachments: readonly ChatAttachmentDropItem[],
|
|
36
|
+
): string {
|
|
37
|
+
const parts = [
|
|
38
|
+
displayText,
|
|
39
|
+
...attachments.map(item => item.text.trim()).filter(Boolean),
|
|
40
|
+
].filter(Boolean);
|
|
41
|
+
return parts.join('\n\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve file attachments on a send payload. */
|
|
45
|
+
export function normalizeUserTextFileAttachments(
|
|
46
|
+
payload: ChatSendMessagePayload,
|
|
47
|
+
): UserTextFileAttachment[] {
|
|
48
|
+
return payload.userTextFileAttachments ?? [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build `sendMessage` input from composer text and staged drop items.
|
|
53
|
+
* Returns a plain string when there are no attachments.
|
|
54
|
+
*/
|
|
55
|
+
export function buildChatSendMessagePayload(
|
|
56
|
+
displayText: string,
|
|
57
|
+
attachments: readonly ChatAttachmentDropItem[],
|
|
58
|
+
): string | ChatSendMessagePayload {
|
|
59
|
+
const trimmed = displayText.trim();
|
|
60
|
+
if (attachments.length === 0) {
|
|
61
|
+
return trimmed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const userTextFileAttachments = attachments.map(dropItemToUserAttachment);
|
|
65
|
+
const resolvedDisplayText =
|
|
66
|
+
trimmed.length > 0 ? trimmed : userTextFileAttachments[0].displayName;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
apiMessage: buildApiMessage(resolvedDisplayText, attachments),
|
|
70
|
+
displayText: resolvedDisplayText,
|
|
71
|
+
userTextFileAttachments,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Display text from a string or structured send payload. */
|
|
76
|
+
export function displayTextFromSendPayload(
|
|
77
|
+
message: string | ChatSendMessagePayload,
|
|
78
|
+
): string {
|
|
79
|
+
return typeof message === 'string' ? message : message.displayText;
|
|
80
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
304
|
-
role === MessageRole.USER
|
|
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
|
-
...(
|
|
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
|
-
|
|
380
|
+
userTextFileAttachments: normalizeUserTextFileAttachments(message),
|
|
376
381
|
},
|
|
377
382
|
);
|
|
378
383
|
}
|
|
@@ -42,11 +42,13 @@ function makeUserMessageWithCsv(
|
|
|
42
42
|
role: MessageRole.USER,
|
|
43
43
|
text: displayText,
|
|
44
44
|
timestamp: Date.now(),
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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,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
|
-
}
|