orchid-ai 2.1.4 → 2.1.5
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/orchid-ai.css +248 -3
- package/package.json +1 -1
- package/src/components/ChatInput.jsx +199 -43
- package/src/components/ChatWindow.jsx +62 -3
- package/src/components/Message.jsx +213 -8
- package/src/hooks/useOrchidAiChat.js +236 -145
- package/src/index.d.ts +45 -2
package/orchid-ai.css
CHANGED
|
@@ -374,6 +374,39 @@
|
|
|
374
374
|
color: #ffffff;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
/* ── Attachment display inside user bubbles ── */
|
|
378
|
+
|
|
379
|
+
.ai-chat-user-attachments {
|
|
380
|
+
display: flex;
|
|
381
|
+
flex-wrap: wrap;
|
|
382
|
+
gap: 8px;
|
|
383
|
+
margin-bottom: 8px;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.ai-chat-attachment-img {
|
|
387
|
+
max-width: 220px;
|
|
388
|
+
max-height: 180px;
|
|
389
|
+
border-radius: 8px;
|
|
390
|
+
object-fit: cover;
|
|
391
|
+
display: block;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.ai-chat-attachment-file {
|
|
395
|
+
display: flex;
|
|
396
|
+
align-items: center;
|
|
397
|
+
gap: 6px;
|
|
398
|
+
padding: 6px 10px;
|
|
399
|
+
background: rgba(255, 255, 255, 0.18);
|
|
400
|
+
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
401
|
+
border-radius: 8px;
|
|
402
|
+
font-size: 12px;
|
|
403
|
+
color: rgba(255, 255, 255, 0.9);
|
|
404
|
+
max-width: 200px;
|
|
405
|
+
overflow: hidden;
|
|
406
|
+
text-overflow: ellipsis;
|
|
407
|
+
white-space: nowrap;
|
|
408
|
+
}
|
|
409
|
+
|
|
377
410
|
.ai-chat-bubble.assistant {
|
|
378
411
|
position: relative;
|
|
379
412
|
background: #ffffff;
|
|
@@ -2183,6 +2216,23 @@
|
|
|
2183
2216
|
word-break: break-word;
|
|
2184
2217
|
}
|
|
2185
2218
|
|
|
2219
|
+
.ai-chat-process-url {
|
|
2220
|
+
color: var(--ai-accent, #3b82f6);
|
|
2221
|
+
text-decoration: none;
|
|
2222
|
+
}
|
|
2223
|
+
.ai-chat-process-url:hover {
|
|
2224
|
+
text-decoration: underline;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
.ai-chat-process-url-list {
|
|
2228
|
+
list-style: none;
|
|
2229
|
+
margin: 0;
|
|
2230
|
+
padding: 0;
|
|
2231
|
+
display: flex;
|
|
2232
|
+
flex-direction: column;
|
|
2233
|
+
gap: 2px;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2186
2236
|
/* ── Message Actions ── */
|
|
2187
2237
|
|
|
2188
2238
|
.ai-chat-message-actions {
|
|
@@ -2279,6 +2329,74 @@
|
|
|
2279
2329
|
height: 5px;
|
|
2280
2330
|
}
|
|
2281
2331
|
|
|
2332
|
+
/* Queued (pending) messages — shown as muted user bubbles while a prior request runs */
|
|
2333
|
+
|
|
2334
|
+
.ai-chat-message--queued {
|
|
2335
|
+
animation: none;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
.ai-chat-bubble--queued {
|
|
2339
|
+
opacity: 0.72;
|
|
2340
|
+
display: flex;
|
|
2341
|
+
flex-direction: column;
|
|
2342
|
+
gap: 8px;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
.ai-chat-pending-msg-text {
|
|
2346
|
+
white-space: pre-wrap;
|
|
2347
|
+
word-break: break-word;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
.ai-chat-pending-footer {
|
|
2351
|
+
display: flex;
|
|
2352
|
+
align-items: center;
|
|
2353
|
+
gap: 8px;
|
|
2354
|
+
flex-wrap: wrap;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
.ai-chat-pending-status {
|
|
2358
|
+
font-size: 11px;
|
|
2359
|
+
color: rgba(255, 255, 255, 0.6);
|
|
2360
|
+
font-style: italic;
|
|
2361
|
+
flex: 1;
|
|
2362
|
+
min-width: 0;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
.ai-chat-pending-actions {
|
|
2366
|
+
display: flex;
|
|
2367
|
+
gap: 6px;
|
|
2368
|
+
flex-wrap: wrap;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
.ai-chat-pending-btn {
|
|
2372
|
+
font-size: 11px;
|
|
2373
|
+
font-weight: 500;
|
|
2374
|
+
padding: 3px 10px;
|
|
2375
|
+
border-radius: 5px;
|
|
2376
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
2377
|
+
background: rgba(255, 255, 255, 0.15);
|
|
2378
|
+
color: rgba(255, 255, 255, 0.9);
|
|
2379
|
+
cursor: pointer;
|
|
2380
|
+
transition: background 0.15s, border-color 0.15s;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
.ai-chat-pending-btn:hover {
|
|
2384
|
+
background: rgba(255, 255, 255, 0.25);
|
|
2385
|
+
border-color: rgba(255, 255, 255, 0.5);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
.ai-chat-pending-btn--primary {
|
|
2389
|
+
background: rgba(255, 255, 255, 0.28);
|
|
2390
|
+
border-color: rgba(255, 255, 255, 0.55);
|
|
2391
|
+
color: #ffffff;
|
|
2392
|
+
font-weight: 600;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
.ai-chat-pending-btn--primary:hover {
|
|
2396
|
+
background: rgba(255, 255, 255, 0.42);
|
|
2397
|
+
border-color: rgba(255, 255, 255, 0.75);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2282
2400
|
.ai-chat-streaming-status {
|
|
2283
2401
|
display: flex;
|
|
2284
2402
|
align-items: center;
|
|
@@ -2318,6 +2436,19 @@
|
|
|
2318
2436
|
border: 1px solid #e5e7eb;
|
|
2319
2437
|
}
|
|
2320
2438
|
|
|
2439
|
+
.ai-building-block--stopped {
|
|
2440
|
+
background: #fafafa;
|
|
2441
|
+
border-color: #e5e7eb;
|
|
2442
|
+
opacity: 0.65;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
.ai-chat-interrupted-notice {
|
|
2446
|
+
font-size: 12px;
|
|
2447
|
+
color: #9ca3af;
|
|
2448
|
+
font-style: italic;
|
|
2449
|
+
margin-top: 8px;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2321
2452
|
.ai-building-block__label {
|
|
2322
2453
|
font-size: 13px;
|
|
2323
2454
|
font-weight: 600;
|
|
@@ -2357,18 +2488,123 @@
|
|
|
2357
2488
|
|
|
2358
2489
|
.ai-chat-input-wrapper {
|
|
2359
2490
|
display: flex;
|
|
2360
|
-
|
|
2491
|
+
flex-direction: column;
|
|
2361
2492
|
position: relative;
|
|
2362
|
-
gap: 10px;
|
|
2363
2493
|
max-width: 768px;
|
|
2364
2494
|
margin: 0 auto;
|
|
2365
2495
|
background: #f9fafb;
|
|
2366
2496
|
border: 1px solid #d1d5db;
|
|
2367
2497
|
border-radius: 14px;
|
|
2368
|
-
|
|
2498
|
+
overflow: hidden;
|
|
2369
2499
|
transition: border-color 0.2s, box-shadow 0.2s;
|
|
2370
2500
|
}
|
|
2371
2501
|
|
|
2502
|
+
/* ── Attachment preview strip ── */
|
|
2503
|
+
|
|
2504
|
+
.ai-attach-strip {
|
|
2505
|
+
display: flex;
|
|
2506
|
+
flex-wrap: wrap;
|
|
2507
|
+
gap: 6px;
|
|
2508
|
+
padding: 10px 12px 8px;
|
|
2509
|
+
border-bottom: 1px solid #e5e7eb;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
.ai-attach-chip {
|
|
2513
|
+
display: flex;
|
|
2514
|
+
align-items: center;
|
|
2515
|
+
gap: 5px;
|
|
2516
|
+
padding: 4px 6px 4px 8px;
|
|
2517
|
+
background: #e5e7eb;
|
|
2518
|
+
border-radius: 8px;
|
|
2519
|
+
font-size: 12px;
|
|
2520
|
+
color: #374151;
|
|
2521
|
+
max-width: 180px;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
.ai-attach-chip--img {
|
|
2525
|
+
padding: 3px 6px 3px 3px;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
.ai-attach-thumb {
|
|
2529
|
+
width: 42px;
|
|
2530
|
+
height: 42px;
|
|
2531
|
+
object-fit: cover;
|
|
2532
|
+
border-radius: 6px;
|
|
2533
|
+
flex-shrink: 0;
|
|
2534
|
+
display: block;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
.ai-attach-chip-icon {
|
|
2538
|
+
display: flex;
|
|
2539
|
+
align-items: center;
|
|
2540
|
+
color: #6b7280;
|
|
2541
|
+
flex-shrink: 0;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
.ai-attach-chip-name {
|
|
2545
|
+
flex: 1;
|
|
2546
|
+
overflow: hidden;
|
|
2547
|
+
text-overflow: ellipsis;
|
|
2548
|
+
white-space: nowrap;
|
|
2549
|
+
min-width: 0;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
.ai-attach-chip-remove {
|
|
2553
|
+
border: none;
|
|
2554
|
+
background: none;
|
|
2555
|
+
color: #9ca3af;
|
|
2556
|
+
cursor: pointer;
|
|
2557
|
+
padding: 0 1px;
|
|
2558
|
+
font-size: 16px;
|
|
2559
|
+
line-height: 1;
|
|
2560
|
+
flex-shrink: 0;
|
|
2561
|
+
display: flex;
|
|
2562
|
+
align-items: center;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
.ai-attach-chip-remove:hover {
|
|
2566
|
+
color: #ef4444;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
/* ── Input row (attach btn + textarea + send btn) ── */
|
|
2570
|
+
|
|
2571
|
+
.ai-chat-input-row {
|
|
2572
|
+
display: flex;
|
|
2573
|
+
align-items: center;
|
|
2574
|
+
gap: 8px;
|
|
2575
|
+
padding: 8px 8px 8px 10px;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
.ai-attach-file-input {
|
|
2579
|
+
display: none;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
.ai-attach-btn {
|
|
2583
|
+
display: flex;
|
|
2584
|
+
align-items: center;
|
|
2585
|
+
justify-content: center;
|
|
2586
|
+
width: 32px;
|
|
2587
|
+
height: 32px;
|
|
2588
|
+
border: none;
|
|
2589
|
+
border-radius: 8px;
|
|
2590
|
+
background: transparent;
|
|
2591
|
+
color: #9ca3af;
|
|
2592
|
+
cursor: pointer;
|
|
2593
|
+
flex-shrink: 0;
|
|
2594
|
+
padding: 0;
|
|
2595
|
+
transition: color 0.15s, background 0.15s;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
.ai-attach-btn:hover:not(:disabled) {
|
|
2599
|
+
color: #4b5563;
|
|
2600
|
+
background: #f3f4f6;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
.ai-attach-btn:disabled {
|
|
2604
|
+
opacity: 0.4;
|
|
2605
|
+
cursor: not-allowed;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2372
2608
|
.ai-chat-input-wrapper:focus-within {
|
|
2373
2609
|
border-color: #1eaaf1;
|
|
2374
2610
|
box-shadow: 0 0 0 3px rgba(30, 170, 241, 0.1);
|
|
@@ -2437,6 +2673,15 @@
|
|
|
2437
2673
|
cursor: not-allowed;
|
|
2438
2674
|
}
|
|
2439
2675
|
|
|
2676
|
+
.ai-chat-stop-btn {
|
|
2677
|
+
background: #ef4444;
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
.ai-chat-stop-btn:hover {
|
|
2681
|
+
background: #dc2626;
|
|
2682
|
+
transform: scale(1.05);
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2440
2685
|
.ai-chat-disclaimer {
|
|
2441
2686
|
font-size: 11px;
|
|
2442
2687
|
color: #9ca3af;
|
package/package.json
CHANGED
|
@@ -1,26 +1,127 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect } from "react";
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const ACCEPTED_TYPES = 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain,text/csv,text/markdown';
|
|
4
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
5
|
+
const MAX_NON_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
6
|
+
const MAX_IMAGE_DIMENSION = 1568;
|
|
7
|
+
let _attachId = 0;
|
|
8
|
+
|
|
9
|
+
// Resize image to fit within MAX_IMAGE_DIMENSION on its longest side.
|
|
10
|
+
// Images already within the limit are returned unchanged.
|
|
11
|
+
// Oversized images are resampled and re-encoded as JPEG.
|
|
12
|
+
function resizeImageIfNeeded(dataUrl, origMediaType) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const img = new Image();
|
|
15
|
+
img.onload = () => {
|
|
16
|
+
const { naturalWidth: w, naturalHeight: h } = img;
|
|
17
|
+
if (Math.max(w, h) <= MAX_IMAGE_DIMENSION) {
|
|
18
|
+
resolve({ dataUrl, mediaType: origMediaType });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const scale = MAX_IMAGE_DIMENSION / Math.max(w, h);
|
|
22
|
+
const nw = Math.max(1, Math.round(w * scale));
|
|
23
|
+
const nh = Math.max(1, Math.round(h * scale));
|
|
24
|
+
const canvas = document.createElement('canvas');
|
|
25
|
+
canvas.width = nw;
|
|
26
|
+
canvas.height = nh;
|
|
27
|
+
const ctx = canvas.getContext('2d');
|
|
28
|
+
ctx.fillStyle = '#ffffff';
|
|
29
|
+
ctx.fillRect(0, 0, nw, nh);
|
|
30
|
+
ctx.drawImage(img, 0, 0, nw, nh);
|
|
31
|
+
resolve({ dataUrl: canvas.toDataURL('image/jpeg', 0.85), mediaType: 'image/jpeg' });
|
|
32
|
+
};
|
|
33
|
+
img.onerror = () => resolve({ dataUrl, mediaType: origMediaType });
|
|
34
|
+
img.src = dataUrl;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function FileIcon() {
|
|
39
|
+
return (
|
|
40
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
41
|
+
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
|
42
|
+
<polyline points="13 2 13 9 20 9" />
|
|
43
|
+
</svg>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function ChatInput({ onSend, onStop, loading = false, disabled, disabledReason = "", onDisabledClick }) {
|
|
4
48
|
const [text, setText] = useState("");
|
|
49
|
+
const [attachments, setAttachments] = useState([]);
|
|
5
50
|
const textareaRef = useRef(null);
|
|
51
|
+
const fileInputRef = useRef(null);
|
|
6
52
|
const showDisabledReason = Boolean(disabledReason);
|
|
53
|
+
const canSend = !disabled && (text.trim().length > 0 || attachments.length > 0);
|
|
7
54
|
|
|
8
55
|
useEffect(() => {
|
|
9
|
-
if (!disabled && textareaRef.current)
|
|
10
|
-
textareaRef.current.focus();
|
|
11
|
-
}
|
|
56
|
+
if (!disabled && textareaRef.current) textareaRef.current.focus();
|
|
12
57
|
}, [disabled]);
|
|
13
58
|
|
|
59
|
+
const readFile = useCallback((file) => new Promise((resolve, reject) => {
|
|
60
|
+
if (!file) { reject(new Error('No file')); return; }
|
|
61
|
+
const isImage = IMAGE_TYPES.has(file.type);
|
|
62
|
+
if (!isImage && file.size > MAX_NON_IMAGE_BYTES) {
|
|
63
|
+
reject(new Error(`${file.name || 'File'} exceeds 5 MB`));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const reader = new FileReader();
|
|
67
|
+
reader.onload = async (e) => {
|
|
68
|
+
try {
|
|
69
|
+
const rawDataUrl = e.target.result;
|
|
70
|
+
let dataUrl = rawDataUrl;
|
|
71
|
+
let mediaType = file.type || 'application/octet-stream';
|
|
72
|
+
if (isImage) {
|
|
73
|
+
const resized = await resizeImageIfNeeded(rawDataUrl, file.type);
|
|
74
|
+
dataUrl = resized.dataUrl;
|
|
75
|
+
mediaType = resized.mediaType;
|
|
76
|
+
}
|
|
77
|
+
resolve({
|
|
78
|
+
id: `att-${++_attachId}`,
|
|
79
|
+
name: file.name || (isImage ? 'pasted-image.png' : 'attachment'),
|
|
80
|
+
mediaType,
|
|
81
|
+
data: dataUrl.split(',')[1],
|
|
82
|
+
preview: isImage ? dataUrl : null,
|
|
83
|
+
size: file.size,
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
reject(err);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
reader.onerror = reject;
|
|
90
|
+
reader.readAsDataURL(file);
|
|
91
|
+
}), []);
|
|
92
|
+
|
|
93
|
+
const addFiles = useCallback(async (fileList) => {
|
|
94
|
+
const files = Array.from(fileList).filter(Boolean);
|
|
95
|
+
if (!files.length) return;
|
|
96
|
+
const results = await Promise.allSettled(files.map(readFile));
|
|
97
|
+
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value);
|
|
98
|
+
if (ok.length) setAttachments(prev => [...prev, ...ok]);
|
|
99
|
+
}, [readFile]);
|
|
100
|
+
|
|
101
|
+
const handleFileChange = (e) => {
|
|
102
|
+
if (e.target.files?.length) addFiles(e.target.files);
|
|
103
|
+
e.target.value = '';
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handlePaste = useCallback((e) => {
|
|
107
|
+
const items = Array.from(e.clipboardData?.items ?? []);
|
|
108
|
+
const fileItems = items.filter(it => it.kind === 'file');
|
|
109
|
+
if (!fileItems.length) return;
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
addFiles(fileItems.map(it => it.getAsFile()));
|
|
112
|
+
}, [addFiles]);
|
|
113
|
+
|
|
114
|
+
const removeAttachment = useCallback((id) => {
|
|
115
|
+
setAttachments(prev => prev.filter(a => a.id !== id));
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
14
118
|
const handleSubmit = (e) => {
|
|
15
119
|
e.preventDefault();
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
textareaRef.current.style.height = "auto";
|
|
22
|
-
}
|
|
23
|
-
}
|
|
120
|
+
if (!canSend) return;
|
|
121
|
+
onSend(text.trim(), attachments.length ? attachments : undefined);
|
|
122
|
+
setText("");
|
|
123
|
+
setAttachments([]);
|
|
124
|
+
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
24
125
|
};
|
|
25
126
|
|
|
26
127
|
const handleKeyDown = (e) => {
|
|
@@ -31,17 +132,14 @@ export default function ChatInput({ onSend, disabled, disabledReason = "", onDis
|
|
|
31
132
|
};
|
|
32
133
|
|
|
33
134
|
const handleInput = (e) => {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
setText(
|
|
135
|
+
const ta = e.target;
|
|
136
|
+
ta.style.height = "auto";
|
|
137
|
+
ta.style.height = Math.min(ta.scrollHeight, 150) + "px";
|
|
138
|
+
setText(ta.value);
|
|
38
139
|
};
|
|
39
140
|
|
|
40
141
|
const handleDisabledInteraction = (e) => {
|
|
41
|
-
if (!disabled || !onDisabledClick)
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
|
|
142
|
+
if (!disabled || !onDisabledClick) return;
|
|
45
143
|
e.preventDefault();
|
|
46
144
|
onDisabledClick();
|
|
47
145
|
};
|
|
@@ -49,7 +147,7 @@ export default function ChatInput({ onSend, disabled, disabledReason = "", onDis
|
|
|
49
147
|
return (
|
|
50
148
|
<form className="ai-chat-input-form" onSubmit={handleSubmit}>
|
|
51
149
|
<div
|
|
52
|
-
className={`ai-chat-input-wrapper
|
|
150
|
+
className={`ai-chat-input-wrapper${showDisabledReason ? " disabled-with-reason" : ""}`}
|
|
53
151
|
onMouseDown={handleDisabledInteraction}
|
|
54
152
|
>
|
|
55
153
|
{showDisabledReason && (
|
|
@@ -61,27 +159,85 @@ export default function ChatInput({ onSend, disabled, disabledReason = "", onDis
|
|
|
61
159
|
aria-label={disabledReason}
|
|
62
160
|
/>
|
|
63
161
|
)}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
162
|
+
{attachments.length > 0 && (
|
|
163
|
+
<div className="ai-attach-strip">
|
|
164
|
+
{attachments.map((att) => (
|
|
165
|
+
<div key={att.id} className={`ai-attach-chip${att.preview ? " ai-attach-chip--img" : ""}`}>
|
|
166
|
+
{att.preview
|
|
167
|
+
? <img src={att.preview} className="ai-attach-thumb" alt={att.name} />
|
|
168
|
+
: <span className="ai-attach-chip-icon"><FileIcon /></span>
|
|
169
|
+
}
|
|
170
|
+
<span className="ai-attach-chip-name">{att.name}</span>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
className="ai-attach-chip-remove"
|
|
174
|
+
onClick={() => removeAttachment(att.id)}
|
|
175
|
+
aria-label={`Remove ${att.name}`}
|
|
176
|
+
>×</button>
|
|
177
|
+
</div>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
<div className="ai-chat-input-row">
|
|
182
|
+
<input
|
|
183
|
+
ref={fileInputRef}
|
|
184
|
+
type="file"
|
|
185
|
+
accept={ACCEPTED_TYPES}
|
|
186
|
+
multiple
|
|
187
|
+
className="ai-attach-file-input"
|
|
188
|
+
onChange={handleFileChange}
|
|
189
|
+
tabIndex={-1}
|
|
190
|
+
aria-hidden="true"
|
|
191
|
+
/>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
className="ai-attach-btn"
|
|
195
|
+
onClick={() => fileInputRef.current?.click()}
|
|
196
|
+
disabled={disabled}
|
|
197
|
+
title="Attach file or image"
|
|
198
|
+
aria-label="Attach file or image"
|
|
199
|
+
>
|
|
200
|
+
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
201
|
+
<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" />
|
|
202
|
+
</svg>
|
|
203
|
+
</button>
|
|
204
|
+
<textarea
|
|
205
|
+
ref={textareaRef}
|
|
206
|
+
className="ai-chat-input"
|
|
207
|
+
value={text}
|
|
208
|
+
onChange={handleInput}
|
|
209
|
+
onKeyDown={handleKeyDown}
|
|
210
|
+
onPaste={handlePaste}
|
|
211
|
+
placeholder={showDisabledReason ? "Select a client to start chatting..." : "Type your question..."}
|
|
212
|
+
disabled={disabled}
|
|
213
|
+
rows={1}
|
|
214
|
+
/>
|
|
215
|
+
{loading ? (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
className="ai-chat-send-btn ai-chat-stop-btn"
|
|
219
|
+
onClick={onStop}
|
|
220
|
+
title="Stop generating"
|
|
221
|
+
aria-label="Stop generating"
|
|
222
|
+
>
|
|
223
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
224
|
+
<rect x="2" y="2" width="12" height="12" rx="2" />
|
|
225
|
+
</svg>
|
|
226
|
+
</button>
|
|
227
|
+
) : (
|
|
228
|
+
<button
|
|
229
|
+
type="submit"
|
|
230
|
+
className="ai-chat-send-btn"
|
|
231
|
+
disabled={!canSend}
|
|
232
|
+
title="Send message"
|
|
233
|
+
>
|
|
234
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
235
|
+
<line x1="22" y1="2" x2="11" y2="13" />
|
|
236
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
|
237
|
+
</svg>
|
|
238
|
+
</button>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
85
241
|
</div>
|
|
86
242
|
<p className="ai-chat-disclaimer">
|
|
87
243
|
Responses are generated by AI and may contain errors. Double-check anything important. Data in the app is
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useEffect } from 'react';
|
|
1
|
+
import React, { useRef, useEffect, useCallback } from 'react';
|
|
2
2
|
import Message from './Message';
|
|
3
3
|
|
|
4
4
|
const DEFAULT_SUGGESTIONS = [
|
|
@@ -14,6 +14,9 @@ export default function ChatWindow({
|
|
|
14
14
|
messages,
|
|
15
15
|
loading,
|
|
16
16
|
statusText,
|
|
17
|
+
queuedMessages,
|
|
18
|
+
onCancelQueue,
|
|
19
|
+
onStop,
|
|
17
20
|
onSuggestionClick,
|
|
18
21
|
aiEnabled,
|
|
19
22
|
organisationName,
|
|
@@ -27,9 +30,40 @@ export default function ChatWindow({
|
|
|
27
30
|
}) {
|
|
28
31
|
const exportPrefix = appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
29
32
|
const bottomRef = useRef(null);
|
|
33
|
+
const scrollerRef = useRef(null);
|
|
34
|
+
const stickToBottomRef = useRef(true);
|
|
35
|
+
const prevLoadingRef = useRef(false);
|
|
30
36
|
|
|
37
|
+
// Track whether the user has scrolled away from the bottom. Only update on actual
|
|
38
|
+
// scroll events — not on content growth — so appending text never disengages sticky.
|
|
39
|
+
const handleScroll = useCallback(() => {
|
|
40
|
+
const el = scrollerRef.current;
|
|
41
|
+
if (!el) return;
|
|
42
|
+
stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
// Re-engage sticky scroll whenever a new request starts so the user always sees
|
|
46
|
+
// their message and the incoming response, even if they had scrolled up.
|
|
31
47
|
useEffect(() => {
|
|
32
|
-
|
|
48
|
+
if (loading && !prevLoadingRef.current) {
|
|
49
|
+
stickToBottomRef.current = true;
|
|
50
|
+
}
|
|
51
|
+
prevLoadingRef.current = loading;
|
|
52
|
+
}, [loading]);
|
|
53
|
+
|
|
54
|
+
// Pin the viewport to the bottom on every streaming delta. Direct scrollTop
|
|
55
|
+
// assignment is instant so the bubble always extends naturally from the bottom
|
|
56
|
+
// of the screen with no lag or chasing. A single smooth scroll runs when
|
|
57
|
+
// streaming ends to settle the final render.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!stickToBottomRef.current) return;
|
|
60
|
+
const el = scrollerRef.current;
|
|
61
|
+
if (!el) return;
|
|
62
|
+
if (loading) {
|
|
63
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
64
|
+
} else {
|
|
65
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
66
|
+
}
|
|
33
67
|
}, [messages, loading, statusText]);
|
|
34
68
|
|
|
35
69
|
const renderEmptyState = () => {
|
|
@@ -106,7 +140,7 @@ export default function ChatWindow({
|
|
|
106
140
|
const hasStreamingMessage = lastMsg?.isStreaming === true;
|
|
107
141
|
|
|
108
142
|
return (
|
|
109
|
-
<div className="ai-chat-window">
|
|
143
|
+
<div className="ai-chat-window" ref={scrollerRef} onScroll={handleScroll}>
|
|
110
144
|
{messages?.length === 0 && !loading && renderEmptyState()}
|
|
111
145
|
{(messages ?? []).map((msg, i) => {
|
|
112
146
|
const isLast = i === (messages?.length ?? 0) - 1;
|
|
@@ -120,12 +154,14 @@ export default function ChatWindow({
|
|
|
120
154
|
truncated={msg.truncated}
|
|
121
155
|
exportPrefix={exportPrefix}
|
|
122
156
|
isStreaming={msg.isStreaming}
|
|
157
|
+
stopped={msg.stopped}
|
|
123
158
|
streamingStatusText={streamingStatusText}
|
|
124
159
|
processTrace={msg.processTrace}
|
|
125
160
|
processInterimLive={msg.processInterimLive}
|
|
126
161
|
showProcessTracePanel={showProcessTracePanel}
|
|
127
162
|
queryContext={msg.queryContext}
|
|
128
163
|
showQuerySummary={showQuerySummary}
|
|
164
|
+
attachments={msg.attachments}
|
|
129
165
|
/>
|
|
130
166
|
);
|
|
131
167
|
})}
|
|
@@ -146,6 +182,29 @@ export default function ChatWindow({
|
|
|
146
182
|
</div>
|
|
147
183
|
</div>
|
|
148
184
|
)}
|
|
185
|
+
{(queuedMessages ?? []).map((msg, i) => (
|
|
186
|
+
<div key={i} className="ai-chat-message user ai-chat-message--queued">
|
|
187
|
+
<div className="ai-chat-avatar user">YOU</div>
|
|
188
|
+
<div className="ai-chat-bubble user ai-chat-bubble--queued">
|
|
189
|
+
<div className="ai-chat-pending-msg-text">{msg}</div>
|
|
190
|
+
<div className="ai-chat-pending-footer">
|
|
191
|
+
<span className="ai-chat-pending-status">Queued</span>
|
|
192
|
+
<div className="ai-chat-pending-actions">
|
|
193
|
+
{onCancelQueue && (
|
|
194
|
+
<button type="button" className="ai-chat-pending-btn" onClick={() => onCancelQueue(i)}>
|
|
195
|
+
Cancel
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
{i === 0 && onStop && (
|
|
199
|
+
<button type="button" className="ai-chat-pending-btn ai-chat-pending-btn--primary" onClick={onStop}>
|
|
200
|
+
Send now
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
149
208
|
<div ref={bottomRef} />
|
|
150
209
|
</div>
|
|
151
210
|
);
|