@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/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 keyboard shortcuts.
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 = [baseClass, className].filter(Boolean).join(' ');
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
- <form className={classes} onSubmit={handleSubmit}>
74
- <textarea
75
- ref={textareaRef}
76
- className={`${baseClass}__textarea`}
77
- value={value}
78
- onChange={(e) => { setValue(e.target.value); resize(); }}
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
- className={`${baseClass}__send`}
87
- type="submit"
88
- disabled={disabled || !value.trim()}
89
- aria-label="Send message"
211
+ type="button"
212
+ className="ec-chat-input__preview-remove"
213
+ onClick={onRemove}
214
+ aria-label={`Remove ${file.name}`}
90
215
  >
91
- <SendIcon />
216
+ &times;
92
217
  </button>
93
- </form>
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, useMemo } from 'react';
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
- * Content rendering is pluggable via `renderContent` or `contentFormat`.
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
- {renderContent
40
- ? renderContent(message.content, message.role)
41
- : <DefaultContent content={message.content} format={contentFormat} />
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
- const html = useMemo(() => {
64
- if (format === 'html') return content;
65
- if (format === 'markdown') return markdownToHtml(content);
66
- return null;
67
- }, [content, format]);
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
+ }