@springbrand/message-panel 0.1.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/demo/camel-chat-showcase.tsx +1265 -0
- package/demo/fixtures.ts +292 -0
- package/demo/index.ts +4 -0
- package/demo/message-panel-chat-showcase.tsx +250 -0
- package/demo/message-panel-gallery.tsx +389 -0
- package/package.json +44 -0
- package/src/adapters/ai-sdk.ts +32 -0
- package/src/camel/camel-chat-messages.tsx +1420 -0
- package/src/camel/camel-model-select.tsx +88 -0
- package/src/camel/camel-plan-bar.tsx +115 -0
- package/src/camel/camel-prompt-input.tsx +540 -0
- package/src/camel/camel-tool-presentation.tsx +1270 -0
- package/src/camel/camel-turn.ts +315 -0
- package/src/camel/index.ts +7 -0
- package/src/camel/turn-summary-bar.tsx +81 -0
- package/src/chat-summary-panel.tsx +229 -0
- package/src/composer/chat-composer.tsx +216 -0
- package/src/composer/composer-attachments.tsx +138 -0
- package/src/composer/composer-trigger-popover.tsx +236 -0
- package/src/composer/composer.tsx +425 -0
- package/src/composer/drop-controller.ts +80 -0
- package/src/composer/index.ts +6 -0
- package/src/composer/types.ts +79 -0
- package/src/contracts.ts +139 -0
- package/src/conversation.tsx +76 -0
- package/src/index.ts +12 -0
- package/src/internal/cn.ts +3 -0
- package/src/message-panel.tsx +368 -0
- package/src/message.tsx +254 -0
- package/src/parts/index.tsx +1149 -0
- package/src/parts/kind.ts +67 -0
- package/src/parts/plan.ts +63 -0
- package/src/parts/primitives.tsx +439 -0
- package/src/registry.ts +44 -0
- package/src/status.tsx +132 -0
- package/src/styles/camel-chat.css +36 -0
- package/src/styles/index.css +2186 -0
- package/src/styles/tokens.css +31 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowUpIcon,
|
|
3
|
+
Loader2Icon,
|
|
4
|
+
PlusIcon,
|
|
5
|
+
SquareIcon,
|
|
6
|
+
} from "lucide-react";
|
|
7
|
+
import {
|
|
8
|
+
forwardRef,
|
|
9
|
+
useCallback,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
type ButtonHTMLAttributes,
|
|
15
|
+
type ClipboardEvent,
|
|
16
|
+
type DragEvent,
|
|
17
|
+
type HTMLAttributes,
|
|
18
|
+
type KeyboardEvent,
|
|
19
|
+
} from "react";
|
|
20
|
+
import { cn } from "../internal/cn";
|
|
21
|
+
import {
|
|
22
|
+
AttachmentPreview,
|
|
23
|
+
AttachmentRemove,
|
|
24
|
+
AttachmentReorder,
|
|
25
|
+
AttachmentRetry,
|
|
26
|
+
Attachments,
|
|
27
|
+
} from "./composer-attachments";
|
|
28
|
+
import { useComposerDrop } from "./drop-controller";
|
|
29
|
+
import type {
|
|
30
|
+
ComposerAttachmentView,
|
|
31
|
+
ComposerProps,
|
|
32
|
+
} from "./types";
|
|
33
|
+
|
|
34
|
+
export const ComposerHeader = forwardRef<
|
|
35
|
+
HTMLDivElement,
|
|
36
|
+
HTMLAttributes<HTMLDivElement>
|
|
37
|
+
>(({ className, ...props }, ref) => (
|
|
38
|
+
<div ref={ref} className={cn("sb-composer__header", className)} {...props} />
|
|
39
|
+
));
|
|
40
|
+
ComposerHeader.displayName = "ComposerHeader";
|
|
41
|
+
|
|
42
|
+
export const ComposerBody = forwardRef<
|
|
43
|
+
HTMLDivElement,
|
|
44
|
+
HTMLAttributes<HTMLDivElement>
|
|
45
|
+
>(({ className, ...props }, ref) => (
|
|
46
|
+
<div ref={ref} className={cn("sb-composer__body", className)} {...props} />
|
|
47
|
+
));
|
|
48
|
+
ComposerBody.displayName = "ComposerBody";
|
|
49
|
+
|
|
50
|
+
export const ComposerFooter = forwardRef<
|
|
51
|
+
HTMLDivElement,
|
|
52
|
+
HTMLAttributes<HTMLDivElement>
|
|
53
|
+
>(({ className, ...props }, ref) => (
|
|
54
|
+
<div ref={ref} className={cn("sb-composer__footer", className)} {...props} />
|
|
55
|
+
));
|
|
56
|
+
ComposerFooter.displayName = "ComposerFooter";
|
|
57
|
+
|
|
58
|
+
export const ComposerTools = forwardRef<
|
|
59
|
+
HTMLDivElement,
|
|
60
|
+
HTMLAttributes<HTMLDivElement>
|
|
61
|
+
>(({ className, ...props }, ref) => (
|
|
62
|
+
<div ref={ref} className={cn("sb-composer__tools", className)} {...props} />
|
|
63
|
+
));
|
|
64
|
+
ComposerTools.displayName = "ComposerTools";
|
|
65
|
+
|
|
66
|
+
export const ComposerSubmit = forwardRef<
|
|
67
|
+
HTMLButtonElement,
|
|
68
|
+
ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
69
|
+
status?: "idle" | "submitted" | "streaming";
|
|
70
|
+
}
|
|
71
|
+
>(({ className, status = "idle", children, ...props }, ref) => (
|
|
72
|
+
<button
|
|
73
|
+
ref={ref}
|
|
74
|
+
type="button"
|
|
75
|
+
className={cn("sb-composer__submit", className)}
|
|
76
|
+
{...props}
|
|
77
|
+
>
|
|
78
|
+
{children ??
|
|
79
|
+
(status === "submitted" ? (
|
|
80
|
+
<Loader2Icon size={16} className="sb-spin" />
|
|
81
|
+
) : status === "streaming" ? (
|
|
82
|
+
<SquareIcon size={15} />
|
|
83
|
+
) : (
|
|
84
|
+
<ArrowUpIcon size={17} />
|
|
85
|
+
))}
|
|
86
|
+
</button>
|
|
87
|
+
));
|
|
88
|
+
ComposerSubmit.displayName = "ComposerSubmit";
|
|
89
|
+
|
|
90
|
+
export function DropOverlay({
|
|
91
|
+
label = "松开以添加文件",
|
|
92
|
+
}: {
|
|
93
|
+
label?: string;
|
|
94
|
+
}) {
|
|
95
|
+
return (
|
|
96
|
+
<div className="sb-composer__drop" role="status">
|
|
97
|
+
<PlusIcon size={20} />
|
|
98
|
+
<span>{label}</span>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ATTACHMENT_DRAG_TYPE = "application/x-sb-message-panel-attachment";
|
|
104
|
+
|
|
105
|
+
function defaultAttachment<T>(attachment: T, index: number): ComposerAttachmentView {
|
|
106
|
+
const record =
|
|
107
|
+
attachment != null && typeof attachment === "object"
|
|
108
|
+
? (attachment as Record<string, unknown>)
|
|
109
|
+
: {};
|
|
110
|
+
return {
|
|
111
|
+
id: String(record.id ?? `attachment-${index}`),
|
|
112
|
+
name: String(record.name ?? record.filename ?? "附件"),
|
|
113
|
+
mediaType:
|
|
114
|
+
typeof record.mediaType === "string" ? record.mediaType : undefined,
|
|
115
|
+
size: typeof record.size === "number" ? record.size : undefined,
|
|
116
|
+
url: typeof record.url === "string" ? record.url : undefined,
|
|
117
|
+
status:
|
|
118
|
+
record.status === "uploading" || record.status === "error"
|
|
119
|
+
? record.status
|
|
120
|
+
: "ready",
|
|
121
|
+
note: typeof record.note === "string" ? record.note : undefined,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function matchesAccept(file: File, accept?: string): boolean {
|
|
126
|
+
if (!accept?.trim()) return true;
|
|
127
|
+
return accept
|
|
128
|
+
.split(",")
|
|
129
|
+
.map((value) => value.trim().toLowerCase())
|
|
130
|
+
.some((rule) => {
|
|
131
|
+
if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule);
|
|
132
|
+
if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1));
|
|
133
|
+
return file.type.toLowerCase() === rule;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ComposerInner<TAttachment>(
|
|
138
|
+
{
|
|
139
|
+
value,
|
|
140
|
+
defaultValue = "",
|
|
141
|
+
onValueChange,
|
|
142
|
+
status,
|
|
143
|
+
attachments = [],
|
|
144
|
+
getAttachment = defaultAttachment,
|
|
145
|
+
dropScope = "composer",
|
|
146
|
+
dropTarget,
|
|
147
|
+
accept,
|
|
148
|
+
multiple = true,
|
|
149
|
+
maxFiles,
|
|
150
|
+
maxFileSize,
|
|
151
|
+
onFilesAdded,
|
|
152
|
+
onAttachmentRemove,
|
|
153
|
+
onAttachmentRetry,
|
|
154
|
+
onAttachmentReorder,
|
|
155
|
+
onValidationError,
|
|
156
|
+
onSubmit,
|
|
157
|
+
onStop,
|
|
158
|
+
placeholder = "问点什么…",
|
|
159
|
+
tools,
|
|
160
|
+
textareaProps,
|
|
161
|
+
textareaRef: externalTextareaRef,
|
|
162
|
+
className,
|
|
163
|
+
...props
|
|
164
|
+
}: ComposerProps<TAttachment>,
|
|
165
|
+
forwardedRef: React.ForwardedRef<HTMLDivElement>,
|
|
166
|
+
) {
|
|
167
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
168
|
+
const resolvedValue = value ?? internalValue;
|
|
169
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
170
|
+
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
171
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
172
|
+
useImperativeHandle(forwardedRef, () => rootRef.current as HTMLDivElement);
|
|
173
|
+
useImperativeHandle(
|
|
174
|
+
externalTextareaRef,
|
|
175
|
+
() => localTextareaRef.current as HTMLTextAreaElement,
|
|
176
|
+
);
|
|
177
|
+
const attachmentViews = useMemo(
|
|
178
|
+
() => attachments.map((attachment, index) => getAttachment(attachment, index)),
|
|
179
|
+
[attachments, getAttachment],
|
|
180
|
+
);
|
|
181
|
+
const generating = status === "submitted" || status === "streaming";
|
|
182
|
+
const allReady = attachmentViews.every(
|
|
183
|
+
(attachment) => attachment.status === "ready",
|
|
184
|
+
);
|
|
185
|
+
const canSend =
|
|
186
|
+
(resolvedValue.trim().length > 0 || attachments.length > 0) &&
|
|
187
|
+
!generating &&
|
|
188
|
+
allReady;
|
|
189
|
+
|
|
190
|
+
const changeValue = useCallback(
|
|
191
|
+
(next: string) => {
|
|
192
|
+
if (value === undefined) setInternalValue(next);
|
|
193
|
+
onValueChange?.(next);
|
|
194
|
+
},
|
|
195
|
+
[value, onValueChange],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const addFiles = useCallback(
|
|
199
|
+
(incoming: FileList | readonly File[]) => {
|
|
200
|
+
const all = Array.from(incoming);
|
|
201
|
+
const remaining =
|
|
202
|
+
maxFiles === undefined
|
|
203
|
+
? all.length
|
|
204
|
+
: Math.max(0, maxFiles - attachments.length);
|
|
205
|
+
const selected = multiple ? all.slice(0, remaining) : all.slice(0, 1);
|
|
206
|
+
const valid = selected.filter(
|
|
207
|
+
(file) =>
|
|
208
|
+
matchesAccept(file, accept) &&
|
|
209
|
+
(maxFileSize === undefined || file.size <= maxFileSize),
|
|
210
|
+
);
|
|
211
|
+
const invalid = selected.filter((file) => !valid.includes(file));
|
|
212
|
+
if (all.length > selected.length) {
|
|
213
|
+
onValidationError?.(`最多只能添加 ${maxFiles ?? 1} 个附件`, all);
|
|
214
|
+
}
|
|
215
|
+
if (invalid.length) {
|
|
216
|
+
onValidationError?.("存在不支持或超过大小限制的文件", invalid);
|
|
217
|
+
}
|
|
218
|
+
if (valid.length) void onFilesAdded?.(valid);
|
|
219
|
+
},
|
|
220
|
+
[
|
|
221
|
+
accept,
|
|
222
|
+
attachments.length,
|
|
223
|
+
maxFiles,
|
|
224
|
+
maxFileSize,
|
|
225
|
+
multiple,
|
|
226
|
+
onFilesAdded,
|
|
227
|
+
onValidationError,
|
|
228
|
+
],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const dragging = useComposerDrop({
|
|
232
|
+
scope: dropScope,
|
|
233
|
+
rootRef,
|
|
234
|
+
target: dropTarget,
|
|
235
|
+
onFiles: addFiles,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const submit = useCallback(async () => {
|
|
239
|
+
if (!canSend) return;
|
|
240
|
+
await onSubmit({ text: resolvedValue.trim(), attachments });
|
|
241
|
+
if (value === undefined) setInternalValue("");
|
|
242
|
+
}, [canSend, onSubmit, resolvedValue, attachments, value]);
|
|
243
|
+
|
|
244
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
245
|
+
textareaProps?.onKeyDown?.(event);
|
|
246
|
+
if (event.defaultPrevented) return;
|
|
247
|
+
if (
|
|
248
|
+
event.key === "Enter" &&
|
|
249
|
+
!event.shiftKey &&
|
|
250
|
+
!event.nativeEvent.isComposing
|
|
251
|
+
) {
|
|
252
|
+
event.preventDefault();
|
|
253
|
+
if (canSend) void submit();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (
|
|
257
|
+
event.key === "Backspace" &&
|
|
258
|
+
event.currentTarget.value === "" &&
|
|
259
|
+
attachments.length > 0
|
|
260
|
+
) {
|
|
261
|
+
const last = attachments[attachments.length - 1];
|
|
262
|
+
if (last) {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
onAttachmentRemove?.(last);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
270
|
+
textareaProps?.onPaste?.(event);
|
|
271
|
+
if (event.defaultPrevented) return;
|
|
272
|
+
const files = Array.from(event.clipboardData.items)
|
|
273
|
+
.filter((item) => item.kind === "file")
|
|
274
|
+
.map((item) => item.getAsFile())
|
|
275
|
+
.filter((file): file is File => file !== null);
|
|
276
|
+
if (files.length) {
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
addFiles(files);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
ref={rootRef}
|
|
285
|
+
className={cn("sb-composer", className)}
|
|
286
|
+
data-drop-scope={dropScope}
|
|
287
|
+
data-dragging={dragging ? "true" : undefined}
|
|
288
|
+
data-status={status}
|
|
289
|
+
{...props}
|
|
290
|
+
>
|
|
291
|
+
<input
|
|
292
|
+
ref={inputRef}
|
|
293
|
+
className="sb-sr-only"
|
|
294
|
+
type="file"
|
|
295
|
+
accept={accept}
|
|
296
|
+
multiple={multiple}
|
|
297
|
+
tabIndex={-1}
|
|
298
|
+
aria-label="上传文件"
|
|
299
|
+
onChange={(event) => {
|
|
300
|
+
if (event.currentTarget.files) addFiles(event.currentTarget.files);
|
|
301
|
+
event.currentTarget.value = "";
|
|
302
|
+
}}
|
|
303
|
+
/>
|
|
304
|
+
{attachments.length > 0 && (
|
|
305
|
+
<ComposerHeader>
|
|
306
|
+
<Attachments>
|
|
307
|
+
{attachments.map((attachment, index) => {
|
|
308
|
+
const view = attachmentViews[index];
|
|
309
|
+
return (
|
|
310
|
+
<AttachmentPreview
|
|
311
|
+
key={view.id}
|
|
312
|
+
attachment={view}
|
|
313
|
+
draggable={Boolean(onAttachmentReorder)}
|
|
314
|
+
aria-roledescription={
|
|
315
|
+
onAttachmentReorder ? "可排序附件" : undefined
|
|
316
|
+
}
|
|
317
|
+
onDragStart={(event: DragEvent<HTMLDivElement>) => {
|
|
318
|
+
if (!onAttachmentReorder) return;
|
|
319
|
+
event.dataTransfer.effectAllowed = "move";
|
|
320
|
+
event.dataTransfer.setData(
|
|
321
|
+
ATTACHMENT_DRAG_TYPE,
|
|
322
|
+
String(index),
|
|
323
|
+
);
|
|
324
|
+
}}
|
|
325
|
+
onDragOver={(event: DragEvent<HTMLDivElement>) => {
|
|
326
|
+
if (
|
|
327
|
+
onAttachmentReorder &&
|
|
328
|
+
event.dataTransfer.types.includes(ATTACHMENT_DRAG_TYPE)
|
|
329
|
+
) {
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
event.dataTransfer.dropEffect = "move";
|
|
332
|
+
}
|
|
333
|
+
}}
|
|
334
|
+
onDrop={(event: DragEvent<HTMLDivElement>) => {
|
|
335
|
+
if (!onAttachmentReorder) return;
|
|
336
|
+
const fromIndex = Number(
|
|
337
|
+
event.dataTransfer.getData(ATTACHMENT_DRAG_TYPE),
|
|
338
|
+
);
|
|
339
|
+
if (
|
|
340
|
+
Number.isInteger(fromIndex) &&
|
|
341
|
+
fromIndex >= 0 &&
|
|
342
|
+
fromIndex < attachments.length &&
|
|
343
|
+
fromIndex !== index
|
|
344
|
+
) {
|
|
345
|
+
event.preventDefault();
|
|
346
|
+
onAttachmentReorder(fromIndex, index);
|
|
347
|
+
}
|
|
348
|
+
}}
|
|
349
|
+
actions={
|
|
350
|
+
<>
|
|
351
|
+
{view.status === "error" && onAttachmentRetry && (
|
|
352
|
+
<AttachmentRetry onClick={() => void onAttachmentRetry(attachment)} />
|
|
353
|
+
)}
|
|
354
|
+
{onAttachmentReorder && index > 0 && (
|
|
355
|
+
<AttachmentReorder
|
|
356
|
+
direction="left"
|
|
357
|
+
onClick={() => onAttachmentReorder(index, index - 1)}
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
360
|
+
{onAttachmentReorder && index < attachments.length - 1 && (
|
|
361
|
+
<AttachmentReorder
|
|
362
|
+
direction="right"
|
|
363
|
+
onClick={() => onAttachmentReorder(index, index + 1)}
|
|
364
|
+
/>
|
|
365
|
+
)}
|
|
366
|
+
{onAttachmentRemove && (
|
|
367
|
+
<AttachmentRemove
|
|
368
|
+
onClick={() => onAttachmentRemove(attachment)}
|
|
369
|
+
/>
|
|
370
|
+
)}
|
|
371
|
+
</>
|
|
372
|
+
}
|
|
373
|
+
/>
|
|
374
|
+
);
|
|
375
|
+
})}
|
|
376
|
+
</Attachments>
|
|
377
|
+
</ComposerHeader>
|
|
378
|
+
)}
|
|
379
|
+
<ComposerBody>
|
|
380
|
+
<textarea
|
|
381
|
+
{...textareaProps}
|
|
382
|
+
ref={localTextareaRef}
|
|
383
|
+
value={resolvedValue}
|
|
384
|
+
rows={1}
|
|
385
|
+
placeholder={placeholder}
|
|
386
|
+
onChange={(event) => changeValue(event.currentTarget.value)}
|
|
387
|
+
onKeyDown={handleKeyDown}
|
|
388
|
+
onPaste={handlePaste}
|
|
389
|
+
/>
|
|
390
|
+
</ComposerBody>
|
|
391
|
+
<ComposerFooter>
|
|
392
|
+
<ComposerTools>
|
|
393
|
+
<button
|
|
394
|
+
type="button"
|
|
395
|
+
className="sb-composer__tool"
|
|
396
|
+
aria-label="添加图片或文件"
|
|
397
|
+
onClick={() => inputRef.current?.click()}
|
|
398
|
+
>
|
|
399
|
+
<PlusIcon size={17} />
|
|
400
|
+
</button>
|
|
401
|
+
{tools}
|
|
402
|
+
</ComposerTools>
|
|
403
|
+
<ComposerSubmit
|
|
404
|
+
status={
|
|
405
|
+
status === "submitted"
|
|
406
|
+
? "submitted"
|
|
407
|
+
: status === "streaming"
|
|
408
|
+
? "streaming"
|
|
409
|
+
: "idle"
|
|
410
|
+
}
|
|
411
|
+
disabled={generating ? !onStop : !canSend}
|
|
412
|
+
aria-label={generating ? "停止" : "发送"}
|
|
413
|
+
onClick={() => (generating ? void onStop?.() : void submit())}
|
|
414
|
+
/>
|
|
415
|
+
</ComposerFooter>
|
|
416
|
+
{dragging && <DropOverlay />}
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export const Composer = forwardRef(ComposerInner) as <TAttachment>(
|
|
422
|
+
props: ComposerProps<TAttachment> & {
|
|
423
|
+
ref?: React.ForwardedRef<HTMLDivElement>;
|
|
424
|
+
},
|
|
425
|
+
) => React.ReactElement;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect, useState, type RefObject } from "react";
|
|
2
|
+
import type { ComposerDropScope } from "./types";
|
|
3
|
+
|
|
4
|
+
function carriesFiles(event: DragEvent): boolean {
|
|
5
|
+
return event.dataTransfer?.types?.includes("Files") === true;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function useComposerDrop({
|
|
9
|
+
scope,
|
|
10
|
+
rootRef,
|
|
11
|
+
target,
|
|
12
|
+
onFiles,
|
|
13
|
+
}: {
|
|
14
|
+
scope: ComposerDropScope;
|
|
15
|
+
rootRef: RefObject<HTMLElement | null>;
|
|
16
|
+
target?: HTMLElement | null;
|
|
17
|
+
onFiles: (files: FileList) => void;
|
|
18
|
+
}) {
|
|
19
|
+
const [dragging, setDragging] = useState(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const element =
|
|
23
|
+
scope === "document"
|
|
24
|
+
? document
|
|
25
|
+
: scope === "panel"
|
|
26
|
+
? target ??
|
|
27
|
+
rootRef.current?.closest<HTMLElement>("[data-sb-message-panel]") ??
|
|
28
|
+
rootRef.current
|
|
29
|
+
: rootRef.current;
|
|
30
|
+
if (!element) return;
|
|
31
|
+
let depth = 0;
|
|
32
|
+
|
|
33
|
+
const over = (event: Event) => {
|
|
34
|
+
const dragEvent = event as DragEvent;
|
|
35
|
+
if (carriesFiles(dragEvent)) dragEvent.preventDefault();
|
|
36
|
+
};
|
|
37
|
+
const enter = (event: Event) => {
|
|
38
|
+
const dragEvent = event as DragEvent;
|
|
39
|
+
if (!carriesFiles(dragEvent)) return;
|
|
40
|
+
dragEvent.preventDefault();
|
|
41
|
+
depth += 1;
|
|
42
|
+
setDragging(true);
|
|
43
|
+
};
|
|
44
|
+
const leave = (event: Event) => {
|
|
45
|
+
const dragEvent = event as DragEvent;
|
|
46
|
+
if (!carriesFiles(dragEvent)) return;
|
|
47
|
+
depth = Math.max(0, depth - 1);
|
|
48
|
+
if (depth === 0) setDragging(false);
|
|
49
|
+
};
|
|
50
|
+
const drop = (event: Event) => {
|
|
51
|
+
const dragEvent = event as DragEvent;
|
|
52
|
+
if (!carriesFiles(dragEvent)) return;
|
|
53
|
+
dragEvent.preventDefault();
|
|
54
|
+
depth = 0;
|
|
55
|
+
setDragging(false);
|
|
56
|
+
if (dragEvent.dataTransfer?.files.length) onFiles(dragEvent.dataTransfer.files);
|
|
57
|
+
};
|
|
58
|
+
const cancel = () => {
|
|
59
|
+
depth = 0;
|
|
60
|
+
setDragging(false);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
element.addEventListener("dragover", over);
|
|
64
|
+
element.addEventListener("dragenter", enter);
|
|
65
|
+
element.addEventListener("dragleave", leave);
|
|
66
|
+
element.addEventListener("drop", drop);
|
|
67
|
+
window.addEventListener("dragend", cancel);
|
|
68
|
+
window.addEventListener("blur", cancel);
|
|
69
|
+
return () => {
|
|
70
|
+
element.removeEventListener("dragover", over);
|
|
71
|
+
element.removeEventListener("dragenter", enter);
|
|
72
|
+
element.removeEventListener("dragleave", leave);
|
|
73
|
+
element.removeEventListener("drop", drop);
|
|
74
|
+
window.removeEventListener("dragend", cancel);
|
|
75
|
+
window.removeEventListener("blur", cancel);
|
|
76
|
+
};
|
|
77
|
+
}, [scope, target, rootRef, onFiles]);
|
|
78
|
+
|
|
79
|
+
return dragging;
|
|
80
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HTMLAttributes,
|
|
3
|
+
ReactNode,
|
|
4
|
+
RefObject,
|
|
5
|
+
TextareaHTMLAttributes,
|
|
6
|
+
} from "react";
|
|
7
|
+
import type { MessagePanelStatus } from "../contracts";
|
|
8
|
+
|
|
9
|
+
export type ComposerDropScope = "composer" | "panel" | "document";
|
|
10
|
+
export type AttachmentStatus = "uploading" | "ready" | "error";
|
|
11
|
+
|
|
12
|
+
export interface ComposerAttachmentView {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
mediaType?: string;
|
|
16
|
+
size?: number;
|
|
17
|
+
url?: string;
|
|
18
|
+
status: AttachmentStatus;
|
|
19
|
+
note?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ComposerMessage<TAttachment> {
|
|
23
|
+
text: string;
|
|
24
|
+
attachments: readonly TAttachment[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ComposerCommand {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
prompt: string;
|
|
31
|
+
icon?: "plan" | "research" | "file" | "time" | "tool" | "mcp";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ComposerToolbarAction {
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
icon?: "sparkles" | "plus";
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
onSelect: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ComposerProps<TAttachment>
|
|
43
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onSubmit"> {
|
|
44
|
+
value?: string;
|
|
45
|
+
defaultValue?: string;
|
|
46
|
+
onValueChange?: (value: string) => void;
|
|
47
|
+
status: MessagePanelStatus;
|
|
48
|
+
attachments?: readonly TAttachment[];
|
|
49
|
+
getAttachment?: (attachment: TAttachment, index: number) => ComposerAttachmentView;
|
|
50
|
+
dropScope?: ComposerDropScope;
|
|
51
|
+
dropTarget?: HTMLElement | null;
|
|
52
|
+
accept?: string;
|
|
53
|
+
multiple?: boolean;
|
|
54
|
+
maxFiles?: number;
|
|
55
|
+
maxFileSize?: number;
|
|
56
|
+
onFilesAdded?: (files: readonly File[]) => void | Promise<void>;
|
|
57
|
+
onAttachmentRemove?: (attachment: TAttachment) => void;
|
|
58
|
+
onAttachmentRetry?: (attachment: TAttachment) => void | Promise<void>;
|
|
59
|
+
onAttachmentReorder?: (fromIndex: number, toIndex: number) => void;
|
|
60
|
+
onValidationError?: (message: string, files: readonly File[]) => void;
|
|
61
|
+
onSubmit: (message: ComposerMessage<TAttachment>) => void | Promise<void>;
|
|
62
|
+
onStop?: () => void | Promise<void>;
|
|
63
|
+
placeholder?: string;
|
|
64
|
+
tools?: ReactNode;
|
|
65
|
+
textareaProps?: Omit<
|
|
66
|
+
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
67
|
+
"value" | "defaultValue" | "onChange" | "onSubmit"
|
|
68
|
+
>;
|
|
69
|
+
textareaRef?: RefObject<HTMLTextAreaElement | null>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ChatComposerProps<TAttachment>
|
|
73
|
+
extends ComposerProps<TAttachment> {
|
|
74
|
+
commands?: readonly ComposerCommand[];
|
|
75
|
+
toolbarActions?: readonly ComposerToolbarAction[];
|
|
76
|
+
variant?: "docked" | "standalone";
|
|
77
|
+
commandMenuLabel?: string;
|
|
78
|
+
onCommandPick?: (command: ComposerCommand) => void;
|
|
79
|
+
}
|