@extrachill/chat 0.3.1 → 0.5.1
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 +36 -0
- package/css/chat.css +189 -2
- package/dist/Chat.d.ts +10 -1
- package/dist/Chat.d.ts.map +1 -1
- package/dist/Chat.js +3 -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 +1 -0
- package/dist/components/ChatMessage.d.ts.map +1 -1
- package/dist/components/ChatMessage.js +23 -3
- 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 +1 -1
- package/src/Chat.tsx +15 -0
- package/src/api.ts +39 -0
- package/src/components/ChatInput.tsx +173 -28
- package/src/components/ChatMessage.tsx +102 -5
- 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
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode, lazy, Suspense } from 'react';
|
|
2
|
-
import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
|
|
2
|
+
import type { ChatMessage as ChatMessageType, ContentFormat, MediaAttachment } from '../types/index.ts';
|
|
3
3
|
import { markdownToHtml } from '../markdown.ts';
|
|
4
4
|
|
|
5
5
|
const ReactMarkdown = lazy(() => import('react-markdown'));
|
|
@@ -22,6 +22,7 @@ export interface ChatMessageProps {
|
|
|
22
22
|
*
|
|
23
23
|
* User messages align right, assistant messages align left.
|
|
24
24
|
* Markdown content is rendered via react-markdown (lazy-loaded).
|
|
25
|
+
* Media attachments render inline below the text content.
|
|
25
26
|
*/
|
|
26
27
|
export function ChatMessage({
|
|
27
28
|
message,
|
|
@@ -34,13 +35,20 @@ export function ChatMessage({
|
|
|
34
35
|
const roleClass = isUser ? `${baseClass}--user` : `${baseClass}--assistant`;
|
|
35
36
|
const classes = [baseClass, roleClass, className].filter(Boolean).join(' ');
|
|
36
37
|
|
|
38
|
+
const hasText = message.content.trim().length > 0;
|
|
39
|
+
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
40
|
+
|
|
37
41
|
return (
|
|
38
42
|
<div className={classes} data-message-id={message.id}>
|
|
39
43
|
<div className={`${baseClass}__bubble`}>
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
)}
|
|
44
52
|
</div>
|
|
45
53
|
{message.timestamp && (
|
|
46
54
|
<time
|
|
@@ -103,3 +111,92 @@ function formatTime(iso: string): string {
|
|
|
103
111
|
return '';
|
|
104
112
|
}
|
|
105
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
|
+
}
|
package/src/hooks/useChat.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
-
import type { ChatMessage } from '../types/message.ts';
|
|
2
|
+
import type { ChatMessage, ToolCall } from '../types/message.ts';
|
|
3
3
|
import type { ChatSession } from '../types/session.ts';
|
|
4
4
|
import type { ChatAvailability } from '../types/session.ts';
|
|
5
|
-
import type {
|
|
5
|
+
import type { MediaAttachment } from '../types/message.ts';
|
|
6
|
+
import type { FetchFn, ChatApiConfig, SendAttachment } from '../api.ts';
|
|
6
7
|
import {
|
|
7
8
|
sendMessage as apiSendMessage,
|
|
8
9
|
continueResponse as apiContinueResponse,
|
|
@@ -50,6 +51,23 @@ export interface UseChatOptions {
|
|
|
50
51
|
* Called when an error occurs.
|
|
51
52
|
*/
|
|
52
53
|
onError?: (error: Error) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Called after each turn when tool calls are present in the response.
|
|
56
|
+
* Use this to react to tool executions (e.g. invalidate caches,
|
|
57
|
+
* apply diffs to the editor, update external state).
|
|
58
|
+
*/
|
|
59
|
+
onToolCalls?: (toolCalls: ToolCall[]) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Arbitrary metadata forwarded to the backend with each message.
|
|
62
|
+
* Use for context scoping (e.g. `{ selected_pipeline_id: 42 }`,
|
|
63
|
+
* `{ post_id: 100, context: 'editor' }`).
|
|
64
|
+
*/
|
|
65
|
+
metadata?: Record<string, unknown>;
|
|
66
|
+
/**
|
|
67
|
+
* Optional context filter for session listing.
|
|
68
|
+
* Only sessions created in the matching context are shown.
|
|
69
|
+
*/
|
|
70
|
+
sessionContext?: string;
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
/**
|
|
@@ -66,12 +84,19 @@ export interface UseChatReturn {
|
|
|
66
84
|
availability: ChatAvailability;
|
|
67
85
|
/** Active session ID. */
|
|
68
86
|
sessionId: string | null;
|
|
87
|
+
/**
|
|
88
|
+
* The session ID that initiated the current request.
|
|
89
|
+
* Use to avoid stale loading indicators when the user switches
|
|
90
|
+
* sessions while a request is in flight.
|
|
91
|
+
* Null when idle.
|
|
92
|
+
*/
|
|
93
|
+
processingSessionId: string | null;
|
|
69
94
|
/** List of sessions. */
|
|
70
95
|
sessions: ChatSession[];
|
|
71
96
|
/** Whether sessions are loading. */
|
|
72
97
|
sessionsLoading: boolean;
|
|
73
|
-
/** Send a user message. */
|
|
74
|
-
sendMessage: (content: string) => void;
|
|
98
|
+
/** Send a user message (with optional file attachments). */
|
|
99
|
+
sendMessage: (content: string, files?: File[]) => void;
|
|
75
100
|
/** Switch to a different session. */
|
|
76
101
|
switchSession: (sessionId: string) => void;
|
|
77
102
|
/** Create a new session. */
|
|
@@ -141,12 +166,16 @@ export function useChat({
|
|
|
141
166
|
maxContinueTurns = 20,
|
|
142
167
|
onMessage,
|
|
143
168
|
onError,
|
|
169
|
+
onToolCalls,
|
|
170
|
+
metadata,
|
|
171
|
+
sessionContext,
|
|
144
172
|
}: UseChatOptions): UseChatReturn {
|
|
145
173
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? []);
|
|
146
174
|
const [isLoading, setIsLoading] = useState(false);
|
|
147
175
|
const [turnCount, setTurnCount] = useState(0);
|
|
148
176
|
const [availability, setAvailability] = useState<ChatAvailability>({ status: 'ready' });
|
|
149
177
|
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
|
178
|
+
const [processingSessionId, setProcessingSessionId] = useState<string | null>(null);
|
|
150
179
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
151
180
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
|
152
181
|
|
|
@@ -157,12 +186,26 @@ export function useChat({
|
|
|
157
186
|
const sessionIdRef = useRef(sessionId);
|
|
158
187
|
sessionIdRef.current = sessionId;
|
|
159
188
|
|
|
189
|
+
// Refs for latest callback/metadata values (avoid stale closures).
|
|
190
|
+
const onToolCallsRef = useRef(onToolCalls);
|
|
191
|
+
onToolCallsRef.current = onToolCalls;
|
|
192
|
+
const metadataRef = useRef(metadata);
|
|
193
|
+
metadataRef.current = metadata;
|
|
194
|
+
const sessionContextRef = useRef(sessionContext);
|
|
195
|
+
sessionContextRef.current = sessionContext;
|
|
196
|
+
// Guard against concurrent session creation.
|
|
197
|
+
const isCreatingRef = useRef(false);
|
|
198
|
+
|
|
160
199
|
// Load sessions on mount
|
|
161
200
|
useEffect(() => {
|
|
162
201
|
const loadSessions = async () => {
|
|
163
202
|
setSessionsLoading(true);
|
|
164
203
|
try {
|
|
165
|
-
const list = await apiListSessions(
|
|
204
|
+
const list = await apiListSessions(
|
|
205
|
+
configRef.current,
|
|
206
|
+
20,
|
|
207
|
+
sessionContextRef.current,
|
|
208
|
+
);
|
|
166
209
|
setSessions(list);
|
|
167
210
|
} catch (err) {
|
|
168
211
|
// Sessions not available — degrade gracefully
|
|
@@ -176,8 +219,47 @@ export function useChat({
|
|
|
176
219
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
177
220
|
}, []);
|
|
178
221
|
|
|
179
|
-
|
|
180
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Collect tool calls from a list of messages and fire the onToolCalls callback.
|
|
224
|
+
*/
|
|
225
|
+
const fireToolCalls = useCallback((msgs: ChatMessage[]) => {
|
|
226
|
+
const cb = onToolCallsRef.current;
|
|
227
|
+
if (!cb) return;
|
|
228
|
+
|
|
229
|
+
const allToolCalls: ToolCall[] = [];
|
|
230
|
+
for (const msg of msgs) {
|
|
231
|
+
if (msg.toolCalls?.length) {
|
|
232
|
+
allToolCalls.push(...msg.toolCalls);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (allToolCalls.length > 0) {
|
|
236
|
+
cb(allToolCalls);
|
|
237
|
+
}
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
const sendMessage = useCallback(async (content: string, files?: File[]) => {
|
|
241
|
+
if (isLoading || isCreatingRef.current) return;
|
|
242
|
+
|
|
243
|
+
// Build optimistic attachment previews from local files.
|
|
244
|
+
let optimisticAttachments: MediaAttachment[] | undefined;
|
|
245
|
+
let sendAttachments: SendAttachment[] | undefined;
|
|
246
|
+
|
|
247
|
+
if (files?.length) {
|
|
248
|
+
optimisticAttachments = files.map((file) => ({
|
|
249
|
+
type: file.type.startsWith('image/') ? 'image' as const
|
|
250
|
+
: file.type.startsWith('video/') ? 'video' as const
|
|
251
|
+
: 'file' as const,
|
|
252
|
+
url: URL.createObjectURL(file),
|
|
253
|
+
filename: file.name,
|
|
254
|
+
mimeType: file.type,
|
|
255
|
+
size: file.size,
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
sendAttachments = files.map((file) => ({
|
|
259
|
+
filename: file.name,
|
|
260
|
+
mime_type: file.type,
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
181
263
|
|
|
182
264
|
// Optimistically add user message
|
|
183
265
|
const userMessage: ChatMessage = {
|
|
@@ -185,27 +267,45 @@ export function useChat({
|
|
|
185
267
|
role: 'user',
|
|
186
268
|
content,
|
|
187
269
|
timestamp: new Date().toISOString(),
|
|
270
|
+
attachments: optimisticAttachments,
|
|
188
271
|
};
|
|
189
272
|
|
|
273
|
+
// Guard against concurrent session creation.
|
|
274
|
+
if (!sessionIdRef.current) {
|
|
275
|
+
isCreatingRef.current = true;
|
|
276
|
+
}
|
|
277
|
+
|
|
190
278
|
setMessages((prev) => [...prev, userMessage]);
|
|
191
279
|
onMessage?.(userMessage);
|
|
192
280
|
setIsLoading(true);
|
|
193
281
|
setTurnCount(0);
|
|
194
282
|
|
|
283
|
+
// Track which session initiated the request.
|
|
284
|
+
const initiatingSessionId = sessionIdRef.current;
|
|
285
|
+
setProcessingSessionId(initiatingSessionId);
|
|
286
|
+
|
|
195
287
|
try {
|
|
196
288
|
const result = await apiSendMessage(
|
|
197
289
|
configRef.current,
|
|
198
290
|
content,
|
|
199
291
|
sessionIdRef.current ?? undefined,
|
|
292
|
+
sendAttachments,
|
|
293
|
+
metadataRef.current,
|
|
200
294
|
);
|
|
201
295
|
|
|
296
|
+
isCreatingRef.current = false;
|
|
297
|
+
|
|
202
298
|
// Update session ID (may be newly created)
|
|
203
299
|
setSessionId(result.sessionId);
|
|
204
300
|
sessionIdRef.current = result.sessionId;
|
|
301
|
+
setProcessingSessionId(result.sessionId);
|
|
205
302
|
|
|
206
303
|
// Replace all messages with the full normalized conversation
|
|
207
304
|
setMessages(result.messages);
|
|
208
305
|
|
|
306
|
+
// Fire tool call callback for the initial response.
|
|
307
|
+
fireToolCalls(result.messages);
|
|
308
|
+
|
|
209
309
|
// Handle multi-turn continuation
|
|
210
310
|
if (!result.completed && !result.maxTurnsReached) {
|
|
211
311
|
let completed = false;
|
|
@@ -225,16 +325,20 @@ export function useChat({
|
|
|
225
325
|
onMessage?.(msg);
|
|
226
326
|
}
|
|
227
327
|
|
|
328
|
+
// Fire tool call callback for each continuation turn.
|
|
329
|
+
fireToolCalls(continuation.messages);
|
|
330
|
+
|
|
228
331
|
completed = continuation.completed || continuation.maxTurnsReached;
|
|
229
332
|
}
|
|
230
333
|
}
|
|
231
334
|
|
|
232
335
|
// Refresh sessions list after a message
|
|
233
|
-
apiListSessions(configRef.current)
|
|
336
|
+
apiListSessions(configRef.current, 20, sessionContextRef.current)
|
|
234
337
|
.then(setSessions)
|
|
235
338
|
.catch(() => { /* ignore */ });
|
|
236
339
|
|
|
237
340
|
} catch (err) {
|
|
341
|
+
isCreatingRef.current = false;
|
|
238
342
|
const error = toError(err);
|
|
239
343
|
onError?.(error);
|
|
240
344
|
|
|
@@ -254,8 +358,9 @@ export function useChat({
|
|
|
254
358
|
} finally {
|
|
255
359
|
setIsLoading(false);
|
|
256
360
|
setTurnCount(0);
|
|
361
|
+
setProcessingSessionId(null);
|
|
257
362
|
}
|
|
258
|
-
}, [isLoading, maxContinueTurns, onMessage, onError]);
|
|
363
|
+
}, [isLoading, maxContinueTurns, onMessage, onError, fireToolCalls]);
|
|
259
364
|
|
|
260
365
|
const switchSession = useCallback(async (newSessionId: string) => {
|
|
261
366
|
setSessionId(newSessionId);
|
|
@@ -301,7 +406,11 @@ export function useChat({
|
|
|
301
406
|
const refreshSessions = useCallback(async () => {
|
|
302
407
|
setSessionsLoading(true);
|
|
303
408
|
try {
|
|
304
|
-
const list = await apiListSessions(
|
|
409
|
+
const list = await apiListSessions(
|
|
410
|
+
configRef.current,
|
|
411
|
+
20,
|
|
412
|
+
sessionContextRef.current,
|
|
413
|
+
);
|
|
305
414
|
setSessions(list);
|
|
306
415
|
} catch (err) {
|
|
307
416
|
onError?.(toError(err));
|
|
@@ -316,6 +425,7 @@ export function useChat({
|
|
|
316
425
|
turnCount,
|
|
317
426
|
availability,
|
|
318
427
|
sessionId,
|
|
428
|
+
processingSessionId,
|
|
319
429
|
sessions,
|
|
320
430
|
sessionsLoading,
|
|
321
431
|
sendMessage,
|
package/src/index.ts
CHANGED
|
@@ -3,18 +3,20 @@ export type {
|
|
|
3
3
|
MessageRole,
|
|
4
4
|
ToolCall,
|
|
5
5
|
ToolResultMeta,
|
|
6
|
+
MediaAttachment,
|
|
6
7
|
ChatMessage,
|
|
7
8
|
ContentFormat,
|
|
8
9
|
ChatSession,
|
|
9
10
|
ChatAvailability,
|
|
10
11
|
ChatInitialState,
|
|
12
|
+
RawAttachment,
|
|
11
13
|
RawMessage,
|
|
12
14
|
RawSession,
|
|
13
15
|
SessionMetadata,
|
|
14
16
|
} from './types/index.ts';
|
|
15
17
|
|
|
16
18
|
// API
|
|
17
|
-
export type { FetchFn, FetchOptions, ChatApiConfig, SendResult, ContinueResult } from './api.ts';
|
|
19
|
+
export type { FetchFn, FetchOptions, ChatApiConfig, SendResult, ContinueResult, SendAttachment } from './api.ts';
|
|
18
20
|
export {
|
|
19
21
|
sendMessage,
|
|
20
22
|
continueResponse,
|