@extrachill/chat 0.3.0 → 0.5.0
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/CHANGELOG.md +31 -0
- package/css/chat.css +189 -2
- package/dist/Chat.d.ts +5 -1
- package/dist/Chat.d.ts.map +1 -1
- package/dist/Chat.js +2 -2
- package/dist/api.d.ts +28 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +25 -2
- package/dist/components/ChatInput.d.ts +11 -4
- package/dist/components/ChatInput.d.ts.map +1 -1
- package/dist/components/ChatInput.js +68 -10
- package/dist/components/ChatMessage.d.ts +2 -2
- package/dist/components/ChatMessage.d.ts.map +1 -1
- package/dist/components/ChatMessage.js +38 -14
- package/dist/hooks/useChat.d.ts +28 -4
- package/dist/hooks/useChat.d.ts.map +1 -1
- package/dist/hooks/useChat.js +70 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/normalizer.d.ts.map +1 -1
- package/dist/normalizer.js +84 -1
- package/dist/types/api.d.ts +25 -1
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/message.d.ts +23 -0
- package/dist/types/message.d.ts.map +1 -1
- package/package.json +6 -3
- package/src/Chat.tsx +8 -0
- package/src/api.ts +39 -0
- package/src/components/ChatInput.tsx +173 -28
- package/src/components/ChatMessage.tsx +128 -16
- package/src/hooks/useChat.ts +120 -10
- package/src/index.ts +3 -1
- package/src/normalizer.ts +88 -3
- package/src/types/api.ts +26 -1
- package/src/types/index.ts +2 -0
- package/src/types/message.ts +24 -0
package/src/api.ts
CHANGED
|
@@ -31,7 +31,12 @@ import { normalizeConversation, normalizeMessage, normalizeSession } from './nor
|
|
|
31
31
|
export interface FetchOptions {
|
|
32
32
|
path: string;
|
|
33
33
|
method?: string;
|
|
34
|
+
/** JSON body (mutually exclusive with formData). */
|
|
34
35
|
data?: Record<string, unknown>;
|
|
36
|
+
/** FormData body for file uploads (mutually exclusive with data). */
|
|
37
|
+
formData?: FormData;
|
|
38
|
+
/** Additional HTTP headers. */
|
|
39
|
+
headers?: Record<string, string>;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export type FetchFn = (options: FetchOptions) => Promise<unknown>;
|
|
@@ -60,22 +65,51 @@ export interface ContinueResult {
|
|
|
60
65
|
maxTurnsReached: boolean;
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Attachment metadata to send with a message.
|
|
70
|
+
*/
|
|
71
|
+
export interface SendAttachment {
|
|
72
|
+
url?: string;
|
|
73
|
+
media_id?: number;
|
|
74
|
+
mime_type?: string;
|
|
75
|
+
filename?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
/**
|
|
64
79
|
* Send a user message (create or continue a session).
|
|
80
|
+
*
|
|
81
|
+
* When attachments are provided, they are included in the JSON body
|
|
82
|
+
* as structured metadata (not as file uploads — files should already
|
|
83
|
+
* be in the WordPress media library or accessible by URL).
|
|
84
|
+
*
|
|
85
|
+
* @param metadata - Arbitrary key-value pairs forwarded to the backend
|
|
86
|
+
* alongside the message (e.g. `{ selected_pipeline_id: 42 }` or
|
|
87
|
+
* `{ post_id: 100, context: 'editor' }`). The backend can use these
|
|
88
|
+
* to scope the AI's behavior. Not persisted as message content.
|
|
65
89
|
*/
|
|
66
90
|
export async function sendMessage(
|
|
67
91
|
config: ChatApiConfig,
|
|
68
92
|
content: string,
|
|
69
93
|
sessionId?: string,
|
|
94
|
+
attachments?: SendAttachment[],
|
|
95
|
+
metadata?: Record<string, unknown>,
|
|
70
96
|
): Promise<SendResult> {
|
|
71
97
|
const body: Record<string, unknown> = { message: content };
|
|
72
98
|
if (sessionId) body.session_id = sessionId;
|
|
73
99
|
if (config.agentId) body.agent_id = config.agentId;
|
|
100
|
+
if (attachments?.length) body.attachments = attachments;
|
|
101
|
+
if (metadata) Object.assign(body, metadata);
|
|
102
|
+
|
|
103
|
+
// Generate a unique request ID for idempotent request handling.
|
|
104
|
+
const requestId = typeof crypto !== 'undefined' && crypto.randomUUID
|
|
105
|
+
? crypto.randomUUID()
|
|
106
|
+
: `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
74
107
|
|
|
75
108
|
const raw = await config.fetchFn({
|
|
76
109
|
path: config.basePath,
|
|
77
110
|
method: 'POST',
|
|
78
111
|
data: body,
|
|
112
|
+
headers: { 'X-Request-ID': requestId },
|
|
79
113
|
}) as SendResponse;
|
|
80
114
|
|
|
81
115
|
if (!raw.success) {
|
|
@@ -118,13 +152,18 @@ export async function continueResponse(
|
|
|
118
152
|
|
|
119
153
|
/**
|
|
120
154
|
* List sessions for the current user.
|
|
155
|
+
*
|
|
156
|
+
* @param context - Optional context filter (e.g. 'chat', 'editor', 'pipeline').
|
|
157
|
+
* Only sessions created in the matching context are returned.
|
|
121
158
|
*/
|
|
122
159
|
export async function listSessions(
|
|
123
160
|
config: ChatApiConfig,
|
|
124
161
|
limit = 20,
|
|
162
|
+
context?: string,
|
|
125
163
|
): Promise<ChatSession[]> {
|
|
126
164
|
const params = new URLSearchParams({ limit: String(limit) });
|
|
127
165
|
if (config.agentId) params.set('agent_id', String(config.agentId));
|
|
166
|
+
if (context) params.set('context', context);
|
|
128
167
|
|
|
129
168
|
const raw = await config.fetchFn({
|
|
130
169
|
path: `${config.basePath}/sessions?${params.toString()}`,
|
|
@@ -1,34 +1,47 @@
|
|
|
1
|
-
import { useState, useRef, useCallback, type KeyboardEvent, type FormEvent } from 'react';
|
|
1
|
+
import { useState, useRef, useCallback, type KeyboardEvent, type FormEvent, type DragEvent, type ClipboardEvent } from 'react';
|
|
2
2
|
|
|
3
3
|
export interface ChatInputProps {
|
|
4
|
-
/** Called when the user submits a message. */
|
|
5
|
-
onSend: (content: string) => void;
|
|
4
|
+
/** Called when the user submits a message (with optional file attachments). */
|
|
5
|
+
onSend: (content: string, files?: File[]) => void;
|
|
6
6
|
/** Whether input is disabled (e.g. while waiting for response). */
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
/** Placeholder text. Defaults to 'Type a message...'. */
|
|
9
9
|
placeholder?: string;
|
|
10
10
|
/** Maximum number of rows the textarea auto-grows to. Defaults to 6. */
|
|
11
11
|
maxRows?: number;
|
|
12
|
+
/** Accepted file types for the file picker. Defaults to 'image/*,video/*'. */
|
|
13
|
+
accept?: string;
|
|
14
|
+
/** Maximum number of files per message. Defaults to 5. */
|
|
15
|
+
maxFiles?: number;
|
|
16
|
+
/** Whether to show the attachment button. Defaults to true. */
|
|
17
|
+
allowAttachments?: boolean;
|
|
12
18
|
/** Additional CSS class name. */
|
|
13
19
|
className?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
|
-
* Chat input with auto-growing textarea and
|
|
23
|
+
* Chat input with auto-growing textarea, keyboard shortcuts, and file attachments.
|
|
18
24
|
*
|
|
19
25
|
* - Enter sends the message
|
|
20
26
|
* - Shift+Enter adds a newline
|
|
21
27
|
* - Textarea auto-grows up to `maxRows`
|
|
28
|
+
* - File attachment via button, drag-and-drop, or clipboard paste
|
|
22
29
|
*/
|
|
23
30
|
export function ChatInput({
|
|
24
31
|
onSend,
|
|
25
32
|
disabled = false,
|
|
26
33
|
placeholder = 'Type a message...',
|
|
27
34
|
maxRows = 6,
|
|
35
|
+
accept = 'image/*,video/*',
|
|
36
|
+
maxFiles = 5,
|
|
37
|
+
allowAttachments = true,
|
|
28
38
|
className,
|
|
29
39
|
}: ChatInputProps) {
|
|
30
40
|
const [value, setValue] = useState('');
|
|
41
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
42
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
31
43
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
44
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
32
45
|
const cooldownRef = useRef(false);
|
|
33
46
|
|
|
34
47
|
const resize = useCallback(() => {
|
|
@@ -40,24 +53,35 @@ export function ChatInput({
|
|
|
40
53
|
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
|
|
41
54
|
}, [maxRows]);
|
|
42
55
|
|
|
56
|
+
const addFiles = useCallback((newFiles: FileList | File[]) => {
|
|
57
|
+
const fileArray = Array.from(newFiles);
|
|
58
|
+
setFiles((prev) => {
|
|
59
|
+
const combined = [...prev, ...fileArray];
|
|
60
|
+
return combined.slice(0, maxFiles);
|
|
61
|
+
});
|
|
62
|
+
}, [maxFiles]);
|
|
63
|
+
|
|
64
|
+
const removeFile = useCallback((index: number) => {
|
|
65
|
+
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
43
68
|
const handleSubmit = useCallback((e?: FormEvent) => {
|
|
44
69
|
e?.preventDefault();
|
|
45
70
|
const trimmed = value.trim();
|
|
46
|
-
if (!trimmed || disabled || cooldownRef.current) return;
|
|
71
|
+
if ((!trimmed && files.length === 0) || disabled || cooldownRef.current) return;
|
|
47
72
|
|
|
48
|
-
// Debounce to prevent double-submit
|
|
49
73
|
cooldownRef.current = true;
|
|
50
74
|
setTimeout(() => { cooldownRef.current = false; }, 300);
|
|
51
75
|
|
|
52
|
-
onSend(trimmed);
|
|
76
|
+
onSend(trimmed, files.length > 0 ? files : undefined);
|
|
53
77
|
setValue('');
|
|
78
|
+
setFiles([]);
|
|
54
79
|
|
|
55
|
-
// Reset textarea height after clearing
|
|
56
80
|
requestAnimationFrame(() => {
|
|
57
81
|
const el = textareaRef.current;
|
|
58
82
|
if (el) el.style.height = 'auto';
|
|
59
83
|
});
|
|
60
|
-
}, [value, disabled, onSend]);
|
|
84
|
+
}, [value, files, disabled, onSend]);
|
|
61
85
|
|
|
62
86
|
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
63
87
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
@@ -66,34 +90,137 @@ export function ChatInput({
|
|
|
66
90
|
}
|
|
67
91
|
}, [handleSubmit]);
|
|
68
92
|
|
|
93
|
+
const handleDragOver = useCallback((e: DragEvent) => {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
if (allowAttachments) setIsDragging(true);
|
|
96
|
+
}, [allowAttachments]);
|
|
97
|
+
|
|
98
|
+
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
setIsDragging(false);
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const handleDrop = useCallback((e: DragEvent) => {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
setIsDragging(false);
|
|
106
|
+
if (!allowAttachments || !e.dataTransfer.files.length) return;
|
|
107
|
+
addFiles(e.dataTransfer.files);
|
|
108
|
+
}, [allowAttachments, addFiles]);
|
|
109
|
+
|
|
110
|
+
const handlePaste = useCallback((e: ClipboardEvent) => {
|
|
111
|
+
if (!allowAttachments) return;
|
|
112
|
+
const pastedFiles = Array.from(e.clipboardData.items)
|
|
113
|
+
.filter((item) => item.kind === 'file')
|
|
114
|
+
.map((item) => item.getAsFile())
|
|
115
|
+
.filter((f): f is File => f !== null);
|
|
116
|
+
|
|
117
|
+
if (pastedFiles.length > 0) {
|
|
118
|
+
addFiles(pastedFiles);
|
|
119
|
+
}
|
|
120
|
+
}, [allowAttachments, addFiles]);
|
|
121
|
+
|
|
69
122
|
const baseClass = 'ec-chat-input';
|
|
70
|
-
const classes = [
|
|
123
|
+
const classes = [
|
|
124
|
+
baseClass,
|
|
125
|
+
isDragging ? `${baseClass}--dragging` : '',
|
|
126
|
+
className,
|
|
127
|
+
].filter(Boolean).join(' ');
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<form
|
|
131
|
+
className={classes}
|
|
132
|
+
onSubmit={handleSubmit}
|
|
133
|
+
onDragOver={handleDragOver}
|
|
134
|
+
onDragLeave={handleDragLeave}
|
|
135
|
+
onDrop={handleDrop}
|
|
136
|
+
>
|
|
137
|
+
{files.length > 0 && (
|
|
138
|
+
<div className={`${baseClass}__attachments`}>
|
|
139
|
+
{files.map((file, i) => (
|
|
140
|
+
<FilePreview key={`${file.name}-${i}`} file={file} onRemove={() => removeFile(i)} />
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
<div className={`${baseClass}__row`}>
|
|
145
|
+
{allowAttachments && (
|
|
146
|
+
<>
|
|
147
|
+
<input
|
|
148
|
+
ref={fileInputRef}
|
|
149
|
+
type="file"
|
|
150
|
+
accept={accept}
|
|
151
|
+
multiple
|
|
152
|
+
className={`${baseClass}__file-input`}
|
|
153
|
+
onChange={(e) => {
|
|
154
|
+
if (e.target.files) addFiles(e.target.files);
|
|
155
|
+
e.target.value = '';
|
|
156
|
+
}}
|
|
157
|
+
tabIndex={-1}
|
|
158
|
+
aria-hidden="true"
|
|
159
|
+
/>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className={`${baseClass}__attach`}
|
|
163
|
+
onClick={() => fileInputRef.current?.click()}
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
aria-label="Attach file"
|
|
166
|
+
title="Attach file"
|
|
167
|
+
>
|
|
168
|
+
<AttachIcon />
|
|
169
|
+
</button>
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
<textarea
|
|
173
|
+
ref={textareaRef}
|
|
174
|
+
className={`${baseClass}__textarea`}
|
|
175
|
+
value={value}
|
|
176
|
+
onChange={(e) => { setValue(e.target.value); resize(); }}
|
|
177
|
+
onKeyDown={handleKeyDown}
|
|
178
|
+
onPaste={handlePaste}
|
|
179
|
+
placeholder={placeholder}
|
|
180
|
+
disabled={disabled}
|
|
181
|
+
rows={1}
|
|
182
|
+
aria-label={placeholder}
|
|
183
|
+
/>
|
|
184
|
+
<button
|
|
185
|
+
className={`${baseClass}__send`}
|
|
186
|
+
type="submit"
|
|
187
|
+
disabled={disabled || (!value.trim() && files.length === 0)}
|
|
188
|
+
aria-label="Send message"
|
|
189
|
+
>
|
|
190
|
+
<SendIcon />
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
</form>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ---- File Preview ---- */
|
|
198
|
+
|
|
199
|
+
function FilePreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
|
200
|
+
const isImage = file.type.startsWith('image/');
|
|
201
|
+
const preview = isImage ? URL.createObjectURL(file) : null;
|
|
71
202
|
|
|
72
203
|
return (
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
onKeyDown={handleKeyDown}
|
|
80
|
-
placeholder={placeholder}
|
|
81
|
-
disabled={disabled}
|
|
82
|
-
rows={1}
|
|
83
|
-
aria-label={placeholder}
|
|
84
|
-
/>
|
|
204
|
+
<div className="ec-chat-input__preview">
|
|
205
|
+
{preview ? (
|
|
206
|
+
<img src={preview} alt={file.name} className="ec-chat-input__preview-image" />
|
|
207
|
+
) : (
|
|
208
|
+
<span className="ec-chat-input__preview-name">{file.name}</span>
|
|
209
|
+
)}
|
|
85
210
|
<button
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
aria-label=
|
|
211
|
+
type="button"
|
|
212
|
+
className="ec-chat-input__preview-remove"
|
|
213
|
+
onClick={onRemove}
|
|
214
|
+
aria-label={`Remove ${file.name}`}
|
|
90
215
|
>
|
|
91
|
-
|
|
216
|
+
×
|
|
92
217
|
</button>
|
|
93
|
-
</
|
|
218
|
+
</div>
|
|
94
219
|
);
|
|
95
220
|
}
|
|
96
221
|
|
|
222
|
+
/* ---- Icons ---- */
|
|
223
|
+
|
|
97
224
|
function SendIcon() {
|
|
98
225
|
return (
|
|
99
226
|
<svg
|
|
@@ -112,3 +239,21 @@ function SendIcon() {
|
|
|
112
239
|
</svg>
|
|
113
240
|
);
|
|
114
241
|
}
|
|
242
|
+
|
|
243
|
+
function AttachIcon() {
|
|
244
|
+
return (
|
|
245
|
+
<svg
|
|
246
|
+
className="ec-chat-input__attach-icon"
|
|
247
|
+
viewBox="0 0 24 24"
|
|
248
|
+
width="20"
|
|
249
|
+
height="20"
|
|
250
|
+
fill="none"
|
|
251
|
+
stroke="currentColor"
|
|
252
|
+
strokeWidth="2"
|
|
253
|
+
strokeLinecap="round"
|
|
254
|
+
strokeLinejoin="round"
|
|
255
|
+
>
|
|
256
|
+
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
|
257
|
+
</svg>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { type ReactNode,
|
|
2
|
-
import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
|
|
1
|
+
import { type ReactNode, lazy, Suspense } from 'react';
|
|
2
|
+
import type { ChatMessage as ChatMessageType, ContentFormat, MediaAttachment } from '../types/index.ts';
|
|
3
3
|
import { markdownToHtml } from '../markdown.ts';
|
|
4
4
|
|
|
5
|
+
const ReactMarkdown = lazy(() => import('react-markdown'));
|
|
6
|
+
|
|
5
7
|
export interface ChatMessageProps {
|
|
6
8
|
/** The message to render. */
|
|
7
9
|
message: ChatMessageType;
|
|
@@ -9,7 +11,6 @@ export interface ChatMessageProps {
|
|
|
9
11
|
contentFormat?: ContentFormat;
|
|
10
12
|
/**
|
|
11
13
|
* Custom content renderer. When provided, overrides contentFormat.
|
|
12
|
-
* Use this to plug in your own markdown renderer (react-markdown, etc.).
|
|
13
14
|
*/
|
|
14
15
|
renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
|
|
15
16
|
/** Additional CSS class name on the outer wrapper. */
|
|
@@ -20,7 +21,8 @@ export interface ChatMessageProps {
|
|
|
20
21
|
* Renders a single chat message bubble.
|
|
21
22
|
*
|
|
22
23
|
* User messages align right, assistant messages align left.
|
|
23
|
-
*
|
|
24
|
+
* Markdown content is rendered via react-markdown (lazy-loaded).
|
|
25
|
+
* Media attachments render inline below the text content.
|
|
24
26
|
*/
|
|
25
27
|
export function ChatMessage({
|
|
26
28
|
message,
|
|
@@ -33,13 +35,20 @@ export function ChatMessage({
|
|
|
33
35
|
const roleClass = isUser ? `${baseClass}--user` : `${baseClass}--assistant`;
|
|
34
36
|
const classes = [baseClass, roleClass, className].filter(Boolean).join(' ');
|
|
35
37
|
|
|
38
|
+
const hasText = message.content.trim().length > 0;
|
|
39
|
+
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
40
|
+
|
|
36
41
|
return (
|
|
37
42
|
<div className={classes} data-message-id={message.id}>
|
|
38
43
|
<div className={`${baseClass}__bubble`}>
|
|
39
|
-
{
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
{hasText && (
|
|
45
|
+
renderContent
|
|
46
|
+
? renderContent(message.content, message.role)
|
|
47
|
+
: <DefaultContent content={message.content} format={contentFormat} />
|
|
48
|
+
)}
|
|
49
|
+
{hasAttachments && (
|
|
50
|
+
<MessageAttachments attachments={message.attachments!} />
|
|
51
|
+
)}
|
|
43
52
|
</div>
|
|
44
53
|
{message.timestamp && (
|
|
45
54
|
<time
|
|
@@ -59,15 +68,29 @@ interface DefaultContentProps {
|
|
|
59
68
|
format: ContentFormat;
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Markdown rendered via lazy-loaded react-markdown.
|
|
73
|
+
* Falls back to the built-in lightweight parser while loading.
|
|
74
|
+
*/
|
|
75
|
+
function MarkdownContent({ content }: { content: string }) {
|
|
76
|
+
return (
|
|
77
|
+
<Suspense
|
|
78
|
+
fallback={
|
|
79
|
+
<div dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }} />
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
<ReactMarkdown>{content}</ReactMarkdown>
|
|
83
|
+
</Suspense>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
function DefaultContent({ content, format }: DefaultContentProps) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (html !== null) {
|
|
70
|
-
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
|
88
|
+
if (format === 'html') {
|
|
89
|
+
return <div dangerouslySetInnerHTML={{ __html: content }} />;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (format === 'markdown') {
|
|
93
|
+
return <MarkdownContent content={content} />;
|
|
71
94
|
}
|
|
72
95
|
|
|
73
96
|
// Plain text — split on double newlines for paragraphs.
|
|
@@ -88,3 +111,92 @@ function formatTime(iso: string): string {
|
|
|
88
111
|
return '';
|
|
89
112
|
}
|
|
90
113
|
}
|
|
114
|
+
|
|
115
|
+
/* ---- Media Attachments ---- */
|
|
116
|
+
|
|
117
|
+
function MessageAttachments({ attachments }: { attachments: MediaAttachment[] }) {
|
|
118
|
+
const images = attachments.filter((a) => a.type === 'image');
|
|
119
|
+
const videos = attachments.filter((a) => a.type === 'video');
|
|
120
|
+
const files = attachments.filter((a) => a.type === 'file');
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="ec-chat-message__attachments">
|
|
124
|
+
{images.length > 0 && (
|
|
125
|
+
<div className="ec-chat-message__images">
|
|
126
|
+
{images.map((img, i) => (
|
|
127
|
+
<a
|
|
128
|
+
key={i}
|
|
129
|
+
href={img.url}
|
|
130
|
+
target="_blank"
|
|
131
|
+
rel="noopener noreferrer"
|
|
132
|
+
className="ec-chat-message__image-link"
|
|
133
|
+
>
|
|
134
|
+
<img
|
|
135
|
+
src={img.thumbnailUrl ?? img.url}
|
|
136
|
+
alt={img.alt ?? img.filename ?? 'Image attachment'}
|
|
137
|
+
className="ec-chat-message__image"
|
|
138
|
+
loading="lazy"
|
|
139
|
+
/>
|
|
140
|
+
</a>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
{videos.map((vid, i) => (
|
|
145
|
+
<video
|
|
146
|
+
key={i}
|
|
147
|
+
src={vid.url}
|
|
148
|
+
controls
|
|
149
|
+
className="ec-chat-message__video"
|
|
150
|
+
preload="metadata"
|
|
151
|
+
>
|
|
152
|
+
<track kind="captions" />
|
|
153
|
+
</video>
|
|
154
|
+
))}
|
|
155
|
+
{files.map((file, i) => (
|
|
156
|
+
<a
|
|
157
|
+
key={i}
|
|
158
|
+
href={file.url}
|
|
159
|
+
download={file.filename}
|
|
160
|
+
className="ec-chat-message__file"
|
|
161
|
+
target="_blank"
|
|
162
|
+
rel="noopener noreferrer"
|
|
163
|
+
>
|
|
164
|
+
<FileIcon />
|
|
165
|
+
<span className="ec-chat-message__file-name">
|
|
166
|
+
{file.filename ?? 'Download file'}
|
|
167
|
+
</span>
|
|
168
|
+
{file.size != null && (
|
|
169
|
+
<span className="ec-chat-message__file-size">
|
|
170
|
+
{formatFileSize(file.size)}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</a>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function FileIcon() {
|
|
180
|
+
return (
|
|
181
|
+
<svg
|
|
182
|
+
className="ec-chat-message__file-icon"
|
|
183
|
+
viewBox="0 0 24 24"
|
|
184
|
+
width="16"
|
|
185
|
+
height="16"
|
|
186
|
+
fill="none"
|
|
187
|
+
stroke="currentColor"
|
|
188
|
+
strokeWidth="2"
|
|
189
|
+
strokeLinecap="round"
|
|
190
|
+
strokeLinejoin="round"
|
|
191
|
+
>
|
|
192
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
193
|
+
<polyline points="14 2 14 8 20 8" />
|
|
194
|
+
</svg>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatFileSize(bytes: number): string {
|
|
199
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
200
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
201
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
202
|
+
}
|