iris-chatbot 5.3.1 → 5.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/package.json +1 -1
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/src/app/api/chat/route.ts +204 -15
- package/template/src/app/api/chat/uploads/route.ts +220 -0
- package/template/src/app/globals.css +174 -3
- package/template/src/components/ChatView.tsx +349 -12
- package/template/src/components/Composer.tsx +93 -3
- package/template/src/components/MessageCard.tsx +204 -49
- package/template/src/lib/attachments.ts +222 -0
- package/template/src/lib/data.ts +6 -2
- package/template/src/lib/types.ts +11 -0
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ArrowUp,
|
|
6
|
+
CornerDownRight,
|
|
7
|
+
File as FileIcon,
|
|
8
|
+
FileText,
|
|
9
|
+
Image as ImageIcon,
|
|
10
|
+
Loader2,
|
|
11
|
+
Mic,
|
|
12
|
+
Plus,
|
|
13
|
+
Square,
|
|
14
|
+
X,
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
import { COMPOSER_FILE_ACCEPT } from "../lib/attachments";
|
|
17
|
+
import type { ChatAttachment } from "../lib/types";
|
|
5
18
|
|
|
6
19
|
function normalizePastedText(input: string): string {
|
|
7
20
|
return input
|
|
@@ -24,6 +37,10 @@ export default function Composer({
|
|
|
24
37
|
micDisabled = false,
|
|
25
38
|
quotedContext = null,
|
|
26
39
|
onClearQuotedContext,
|
|
40
|
+
pendingAttachments = [],
|
|
41
|
+
onAttachFiles,
|
|
42
|
+
onRemovePendingAttachment,
|
|
43
|
+
isUploadingAttachments = false,
|
|
27
44
|
}: {
|
|
28
45
|
value: string;
|
|
29
46
|
onChange: (value: string) => void;
|
|
@@ -36,11 +53,22 @@ export default function Composer({
|
|
|
36
53
|
micDisabled?: boolean;
|
|
37
54
|
quotedContext?: string | null;
|
|
38
55
|
onClearQuotedContext?: () => void;
|
|
56
|
+
pendingAttachments?: Array<{
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
kind: ChatAttachment["kind"];
|
|
60
|
+
}>;
|
|
61
|
+
onAttachFiles?: (files: File[]) => void;
|
|
62
|
+
onRemovePendingAttachment?: (id: string) => void;
|
|
63
|
+
isUploadingAttachments?: boolean;
|
|
39
64
|
}) {
|
|
40
65
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
66
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
41
67
|
const minRowsRef = useRef(1);
|
|
42
68
|
const maxRowsRef = useRef(8);
|
|
43
69
|
const hasText = value.trim().length > 0;
|
|
70
|
+
const hasPendingAttachments = pendingAttachments.length > 0;
|
|
71
|
+
const canSend = hasText || Boolean(quotedContext) || hasPendingAttachments;
|
|
44
72
|
const micButtonTitle =
|
|
45
73
|
micState === "listening"
|
|
46
74
|
? "Stop dictation"
|
|
@@ -140,7 +168,68 @@ export default function Composer({
|
|
|
140
168
|
</button>
|
|
141
169
|
</div>
|
|
142
170
|
) : null}
|
|
171
|
+
{pendingAttachments.length > 0 ? (
|
|
172
|
+
<div className="composer-attachments">
|
|
173
|
+
{pendingAttachments.map((attachment) => {
|
|
174
|
+
const icon =
|
|
175
|
+
attachment.kind === "image" ? (
|
|
176
|
+
<ImageIcon className="h-3.5 w-3.5" />
|
|
177
|
+
) : attachment.kind === "pdf" ? (
|
|
178
|
+
<FileText className="h-3.5 w-3.5" />
|
|
179
|
+
) : (
|
|
180
|
+
<FileIcon className="h-3.5 w-3.5" />
|
|
181
|
+
);
|
|
182
|
+
return (
|
|
183
|
+
<span key={attachment.id} className="composer-attachment-chip">
|
|
184
|
+
<span className="composer-attachment-icon">{icon}</span>
|
|
185
|
+
<span className="composer-attachment-name">{attachment.name}</span>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className="composer-attachment-remove"
|
|
189
|
+
onClick={() => onRemovePendingAttachment?.(attachment.id)}
|
|
190
|
+
aria-label={`Remove ${attachment.name}`}
|
|
191
|
+
>
|
|
192
|
+
<X className="h-3 w-3" />
|
|
193
|
+
</button>
|
|
194
|
+
</span>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</div>
|
|
198
|
+
) : null}
|
|
143
199
|
<div className="composer flex items-end gap-3">
|
|
200
|
+
{onAttachFiles ? (
|
|
201
|
+
<>
|
|
202
|
+
<input
|
|
203
|
+
ref={fileInputRef}
|
|
204
|
+
type="file"
|
|
205
|
+
accept={COMPOSER_FILE_ACCEPT}
|
|
206
|
+
multiple
|
|
207
|
+
className="hidden"
|
|
208
|
+
onChange={(event) => {
|
|
209
|
+
const selectedFiles = event.currentTarget.files;
|
|
210
|
+
if (!selectedFiles || selectedFiles.length === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
onAttachFiles([...selectedFiles]);
|
|
214
|
+
event.currentTarget.value = "";
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className="send-button composer-attach-button shrink-0"
|
|
220
|
+
title={isUploadingAttachments ? "Uploading attachments" : "Add files"}
|
|
221
|
+
aria-label={isUploadingAttachments ? "Uploading attachments" : "Add files"}
|
|
222
|
+
disabled={isStreaming || isUploadingAttachments}
|
|
223
|
+
onClick={() => fileInputRef.current?.click()}
|
|
224
|
+
>
|
|
225
|
+
{isUploadingAttachments ? (
|
|
226
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
227
|
+
) : (
|
|
228
|
+
<Plus className="h-4 w-4" />
|
|
229
|
+
)}
|
|
230
|
+
</button>
|
|
231
|
+
</>
|
|
232
|
+
) : null}
|
|
144
233
|
<textarea
|
|
145
234
|
ref={textareaRef}
|
|
146
235
|
rows={1}
|
|
@@ -159,7 +248,7 @@ export default function Composer({
|
|
|
159
248
|
onKeyDown={(event) => {
|
|
160
249
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
161
250
|
event.preventDefault();
|
|
162
|
-
if (!isStreaming) onSend();
|
|
251
|
+
if (!isStreaming && !isUploadingAttachments) onSend();
|
|
163
252
|
}
|
|
164
253
|
}}
|
|
165
254
|
/>
|
|
@@ -191,7 +280,8 @@ export default function Composer({
|
|
|
191
280
|
) : (
|
|
192
281
|
<button
|
|
193
282
|
onClick={onSend}
|
|
194
|
-
className={`send-button shrink-0 ${
|
|
283
|
+
className={`send-button shrink-0 ${canSend ? "active" : ""}`}
|
|
284
|
+
disabled={isUploadingAttachments}
|
|
195
285
|
>
|
|
196
286
|
<ArrowUp className="h-4 w-4" />
|
|
197
287
|
</button>
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
PlusCircle,
|
|
15
15
|
X,
|
|
16
16
|
} from "lucide-react";
|
|
17
|
-
import { memo, useMemo, useRef, useState } from "react";
|
|
17
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
18
18
|
import type {
|
|
19
19
|
ChatCitationSource,
|
|
20
20
|
MessageNode,
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
ToolApproval,
|
|
23
23
|
ToolEvent,
|
|
24
24
|
} from "../lib/types";
|
|
25
|
+
import { normalizeChatAttachments } from "../lib/attachments";
|
|
25
26
|
import { splitContentAndSources } from "../lib/utils";
|
|
26
27
|
|
|
27
28
|
const MAX_VISIBLE_TOOL_ITEMS = 8;
|
|
@@ -117,6 +118,10 @@ function normalizeMarkdownStructure(content: string) {
|
|
|
117
118
|
return normalized.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
function normalizeUserTextForEditor(content: string): string {
|
|
122
|
+
return content.replace(/\s+/g, " ").trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
120
125
|
function stabilizeStreamingMarkdown(content: string) {
|
|
121
126
|
let stable = content;
|
|
122
127
|
const fenceMatches = stable.match(/```/g);
|
|
@@ -1241,6 +1246,7 @@ function MessageCard({
|
|
|
1241
1246
|
approvals,
|
|
1242
1247
|
onResolveApproval,
|
|
1243
1248
|
approvalBusyIds,
|
|
1249
|
+
onResendEditedUserMessage,
|
|
1244
1250
|
}: {
|
|
1245
1251
|
message: MessageNode;
|
|
1246
1252
|
onAddThread: (message: MessageNode) => void;
|
|
@@ -1258,12 +1264,20 @@ function MessageCard({
|
|
|
1258
1264
|
argsOverride?: Record<string, unknown>,
|
|
1259
1265
|
) => void;
|
|
1260
1266
|
approvalBusyIds?: Record<string, boolean>;
|
|
1267
|
+
onResendEditedUserMessage?: (messageId: string, content: string) => Promise<boolean>;
|
|
1261
1268
|
}) {
|
|
1262
1269
|
const [copied, setCopied] = useState(false);
|
|
1263
1270
|
const [threadAdded, setThreadAdded] = useState(false);
|
|
1264
1271
|
const [assistantCollapsed, setAssistantCollapsed] = useState(false);
|
|
1265
1272
|
const [threadEditMode, setThreadEditMode] = useState(false);
|
|
1273
|
+
const [isEditingUserMessage, setIsEditingUserMessage] = useState(false);
|
|
1274
|
+
const [editedUserContent, setEditedUserContent] = useState("");
|
|
1275
|
+
const [isSubmittingUserEdit, setIsSubmittingUserEdit] = useState(false);
|
|
1276
|
+
const [userEditWidthPx, setUserEditWidthPx] = useState<number | null>(null);
|
|
1277
|
+
const userMessageCardRef = useRef<HTMLDivElement | null>(null);
|
|
1278
|
+
const userEditTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
1266
1279
|
const isAssistant = message.role === "assistant";
|
|
1280
|
+
const canEditUserMessage = !isAssistant && Boolean(onResendEditedUserMessage);
|
|
1267
1281
|
const canAddThread = threads.length < 4;
|
|
1268
1282
|
const shelfThreads = [
|
|
1269
1283
|
...(baseThreadId ? [{ id: baseThreadId, title: "Thread 1", isBase: true }] : []),
|
|
@@ -1279,6 +1293,12 @@ function MessageCard({
|
|
|
1279
1293
|
() => splitContentAndSources(message.content || ""),
|
|
1280
1294
|
[message.content],
|
|
1281
1295
|
);
|
|
1296
|
+
const userAttachments = useMemo(
|
|
1297
|
+
() => (message.role === "user" ? normalizeChatAttachments(message.attachments) : []),
|
|
1298
|
+
[message.role, message.attachments],
|
|
1299
|
+
);
|
|
1300
|
+
const canSubmitUserEdit =
|
|
1301
|
+
editedUserContent.trim().length > 0 || userAttachments.length > 0;
|
|
1282
1302
|
const sourcesOrderedByCitation = useMemo(
|
|
1283
1303
|
() => reorderSourcesByCitationOrder(messageTextContent, messageSources),
|
|
1284
1304
|
[messageTextContent, messageSources],
|
|
@@ -1337,9 +1357,65 @@ function MessageCard({
|
|
|
1337
1357
|
return "Working on your request.";
|
|
1338
1358
|
}, [messageTextContent, timelineItems, approvalItems]);
|
|
1339
1359
|
|
|
1360
|
+
useEffect(() => {
|
|
1361
|
+
if (!isEditingUserMessage) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
requestAnimationFrame(() => {
|
|
1365
|
+
const element = userEditTextareaRef.current;
|
|
1366
|
+
if (!element) {
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
element.focus();
|
|
1370
|
+
const end = element.value.length;
|
|
1371
|
+
element.setSelectionRange(end, end);
|
|
1372
|
+
});
|
|
1373
|
+
}, [isEditingUserMessage]);
|
|
1374
|
+
|
|
1375
|
+
const startUserMessageEdit = () => {
|
|
1376
|
+
const cardWidth = userMessageCardRef.current?.getBoundingClientRect().width ?? null;
|
|
1377
|
+
setUserEditWidthPx(cardWidth && Number.isFinite(cardWidth) ? Math.max(0, Math.round(cardWidth)) : null);
|
|
1378
|
+
setEditedUserContent(normalizeUserTextForEditor(messageTextContent));
|
|
1379
|
+
setIsEditingUserMessage(true);
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
const cancelUserMessageEdit = () => {
|
|
1383
|
+
if (isSubmittingUserEdit) {
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
setIsEditingUserMessage(false);
|
|
1387
|
+
setEditedUserContent("");
|
|
1388
|
+
setUserEditWidthPx(null);
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
const submitUserMessageEdit = async () => {
|
|
1392
|
+
if (!onResendEditedUserMessage || isSubmittingUserEdit || !canSubmitUserEdit) {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
setIsSubmittingUserEdit(true);
|
|
1396
|
+
try {
|
|
1397
|
+
const success = await onResendEditedUserMessage(message.id, editedUserContent);
|
|
1398
|
+
if (success) {
|
|
1399
|
+
setIsEditingUserMessage(false);
|
|
1400
|
+
setEditedUserContent("");
|
|
1401
|
+
setUserEditWidthPx(null);
|
|
1402
|
+
}
|
|
1403
|
+
} finally {
|
|
1404
|
+
setIsSubmittingUserEdit(false);
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1340
1408
|
return (
|
|
1341
1409
|
<div className="message-stack group">
|
|
1342
|
-
<div
|
|
1410
|
+
<div
|
|
1411
|
+
ref={!isAssistant ? userMessageCardRef : null}
|
|
1412
|
+
className={`${isAssistant ? "assistant-card" : "message-card user"} group`}
|
|
1413
|
+
style={
|
|
1414
|
+
!isAssistant && isEditingUserMessage && userEditWidthPx
|
|
1415
|
+
? { width: `${userEditWidthPx}px` }
|
|
1416
|
+
: undefined
|
|
1417
|
+
}
|
|
1418
|
+
>
|
|
1343
1419
|
{isAssistant ? (
|
|
1344
1420
|
<div className="assistant-collapse-row">
|
|
1345
1421
|
<button
|
|
@@ -1415,7 +1491,74 @@ function MessageCard({
|
|
|
1415
1491
|
</ReactMarkdown>
|
|
1416
1492
|
)
|
|
1417
1493
|
) : (
|
|
1418
|
-
<
|
|
1494
|
+
<div className="user-message-content">
|
|
1495
|
+
{userAttachments.length > 0 ? (
|
|
1496
|
+
<div className="user-attachment-row" aria-label="Attachments">
|
|
1497
|
+
{userAttachments.map((attachment, index) => (
|
|
1498
|
+
<span
|
|
1499
|
+
key={`${attachment.providerFileId}-${index}`}
|
|
1500
|
+
className="user-attachment-pill"
|
|
1501
|
+
title={attachment.name}
|
|
1502
|
+
>
|
|
1503
|
+
<span className="user-attachment-kind">
|
|
1504
|
+
{attachment.kind === "image"
|
|
1505
|
+
? "IMG"
|
|
1506
|
+
: attachment.kind === "pdf"
|
|
1507
|
+
? "PDF"
|
|
1508
|
+
: "DOC"}
|
|
1509
|
+
</span>
|
|
1510
|
+
<span className="user-attachment-title">{attachment.name}</span>
|
|
1511
|
+
</span>
|
|
1512
|
+
))}
|
|
1513
|
+
</div>
|
|
1514
|
+
) : null}
|
|
1515
|
+
{isEditingUserMessage ? (
|
|
1516
|
+
<div className="user-edit-shell">
|
|
1517
|
+
<textarea
|
|
1518
|
+
ref={userEditTextareaRef}
|
|
1519
|
+
value={editedUserContent}
|
|
1520
|
+
onChange={(event) => setEditedUserContent(event.target.value)}
|
|
1521
|
+
className="user-edit-textarea"
|
|
1522
|
+
placeholder="Edit your message"
|
|
1523
|
+
rows={4}
|
|
1524
|
+
disabled={isSubmittingUserEdit}
|
|
1525
|
+
onKeyDown={(event) => {
|
|
1526
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
1527
|
+
event.preventDefault();
|
|
1528
|
+
void submitUserMessageEdit();
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (event.key === "Escape") {
|
|
1532
|
+
event.preventDefault();
|
|
1533
|
+
cancelUserMessageEdit();
|
|
1534
|
+
}
|
|
1535
|
+
}}
|
|
1536
|
+
/>
|
|
1537
|
+
<div className="user-edit-actions">
|
|
1538
|
+
<button
|
|
1539
|
+
type="button"
|
|
1540
|
+
className="user-edit-cancel"
|
|
1541
|
+
onClick={cancelUserMessageEdit}
|
|
1542
|
+
disabled={isSubmittingUserEdit}
|
|
1543
|
+
>
|
|
1544
|
+
Cancel
|
|
1545
|
+
</button>
|
|
1546
|
+
<button
|
|
1547
|
+
type="button"
|
|
1548
|
+
className="user-edit-send"
|
|
1549
|
+
onClick={() => {
|
|
1550
|
+
void submitUserMessageEdit();
|
|
1551
|
+
}}
|
|
1552
|
+
disabled={isSubmittingUserEdit || !canSubmitUserEdit}
|
|
1553
|
+
>
|
|
1554
|
+
Send
|
|
1555
|
+
</button>
|
|
1556
|
+
</div>
|
|
1557
|
+
</div>
|
|
1558
|
+
) : messageTextContent ? (
|
|
1559
|
+
<p>{messageTextContent}</p>
|
|
1560
|
+
) : null}
|
|
1561
|
+
</div>
|
|
1419
1562
|
)}
|
|
1420
1563
|
|
|
1421
1564
|
{message.role === "assistant" && !assistantCollapsed && sourcesOrderedByCitation.length > 0 ? (
|
|
@@ -1567,61 +1710,73 @@ function MessageCard({
|
|
|
1567
1710
|
</div>
|
|
1568
1711
|
) : null}
|
|
1569
1712
|
</div>
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1713
|
+
{isAssistant || !isEditingUserMessage ? (
|
|
1714
|
+
<div
|
|
1715
|
+
className={`message-actions ${isAssistant ? "assistant opacity-0 transition-opacity duration-150 hover:opacity-100" : "user"}`}
|
|
1716
|
+
>
|
|
1717
|
+
{isAssistant ? (
|
|
1718
|
+
<button
|
|
1719
|
+
className={`flex items-center justify-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition ${canAddThread
|
|
1720
|
+
? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
|
|
1721
|
+
: "opacity-60 cursor-not-allowed"
|
|
1722
|
+
}`}
|
|
1723
|
+
onClick={async () => {
|
|
1724
|
+
if (!canAddThread) return;
|
|
1725
|
+
await onAddThread(message);
|
|
1726
|
+
setThreadAdded(true);
|
|
1727
|
+
window.setTimeout(() => setThreadAdded(false), 1200);
|
|
1728
|
+
}}
|
|
1729
|
+
disabled={!canAddThread}
|
|
1730
|
+
>
|
|
1731
|
+
{threadAdded ? (
|
|
1732
|
+
<Check className="h-4 w-4 text-[var(--accent)]" />
|
|
1733
|
+
) : (
|
|
1734
|
+
<>
|
|
1735
|
+
<PlusCircle className="h-4 w-4" />
|
|
1736
|
+
Thread
|
|
1737
|
+
</>
|
|
1738
|
+
)}
|
|
1739
|
+
</button>
|
|
1740
|
+
) : null}
|
|
1574
1741
|
<button
|
|
1575
|
-
className={`flex items-center
|
|
1576
|
-
? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
|
|
1577
|
-
: "opacity-60 cursor-not-allowed"
|
|
1742
|
+
className={`flex items-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)] ${isAssistant ? "" : "opacity-0 group-hover:opacity-100"
|
|
1578
1743
|
}`}
|
|
1579
1744
|
onClick={async () => {
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
window.setTimeout(() => setThreadAdded(false), 1200);
|
|
1745
|
+
await navigator.clipboard.writeText(messageTextContent);
|
|
1746
|
+
setCopied(true);
|
|
1747
|
+
window.setTimeout(() => setCopied(false), 1200);
|
|
1584
1748
|
}}
|
|
1585
|
-
disabled={!canAddThread}
|
|
1586
1749
|
>
|
|
1587
|
-
{
|
|
1750
|
+
{copied ? (
|
|
1588
1751
|
<Check className="h-4 w-4 text-[var(--accent)]" />
|
|
1589
1752
|
) : (
|
|
1590
|
-
|
|
1591
|
-
<PlusCircle className="h-4 w-4" />
|
|
1592
|
-
Thread
|
|
1593
|
-
</>
|
|
1753
|
+
<Copy className="h-4 w-4" />
|
|
1594
1754
|
)}
|
|
1595
1755
|
</button>
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
{
|
|
1607
|
-
<
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
>
|
|
1621
|
-
<Pencil className="h-4 w-4" />
|
|
1622
|
-
</button>
|
|
1623
|
-
) : null}
|
|
1624
|
-
</div>
|
|
1756
|
+
{canEditUserMessage ? (
|
|
1757
|
+
<button
|
|
1758
|
+
type="button"
|
|
1759
|
+
className="flex items-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] opacity-0 transition hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)] group-hover:opacity-100"
|
|
1760
|
+
onClick={startUserMessageEdit}
|
|
1761
|
+
aria-label="Edit message"
|
|
1762
|
+
>
|
|
1763
|
+
<Pencil className="h-4 w-4" />
|
|
1764
|
+
</button>
|
|
1765
|
+
) : null}
|
|
1766
|
+
{canEditThreads ? (
|
|
1767
|
+
<button
|
|
1768
|
+
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${threadEditMode
|
|
1769
|
+
? "border-[var(--accent-ring)] text-[var(--text-primary)]"
|
|
1770
|
+
: "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
|
|
1771
|
+
}`}
|
|
1772
|
+
onClick={() => setThreadEditMode((prev) => !prev)}
|
|
1773
|
+
aria-label="Edit threads"
|
|
1774
|
+
>
|
|
1775
|
+
<Pencil className="h-4 w-4" />
|
|
1776
|
+
</button>
|
|
1777
|
+
) : null}
|
|
1778
|
+
</div>
|
|
1779
|
+
) : null}
|
|
1625
1780
|
</div>
|
|
1626
1781
|
);
|
|
1627
1782
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { ChatAttachment } from "./types";
|
|
2
|
+
|
|
3
|
+
export const MAX_ATTACHMENTS_PER_MESSAGE = 8;
|
|
4
|
+
export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif"]);
|
|
7
|
+
const DOCUMENT_EXTENSIONS = new Set(["txt", "md", "doc", "docx"]);
|
|
8
|
+
|
|
9
|
+
const DOCUMENT_MIME_TYPES = new Set([
|
|
10
|
+
"text/plain",
|
|
11
|
+
"text/markdown",
|
|
12
|
+
"text/x-markdown",
|
|
13
|
+
"application/msword",
|
|
14
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const EXTENSION_TO_MIME: Record<string, string> = {
|
|
18
|
+
png: "image/png",
|
|
19
|
+
jpg: "image/jpeg",
|
|
20
|
+
jpeg: "image/jpeg",
|
|
21
|
+
webp: "image/webp",
|
|
22
|
+
gif: "image/gif",
|
|
23
|
+
pdf: "application/pdf",
|
|
24
|
+
txt: "text/plain",
|
|
25
|
+
md: "text/markdown",
|
|
26
|
+
doc: "application/msword",
|
|
27
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const COMPOSER_FILE_ACCEPT =
|
|
31
|
+
".png,.jpg,.jpeg,.webp,.gif,.pdf,.txt,.md,.doc,.docx";
|
|
32
|
+
|
|
33
|
+
type AttachmentConnection = {
|
|
34
|
+
kind?: unknown;
|
|
35
|
+
provider?: unknown;
|
|
36
|
+
} | null | undefined;
|
|
37
|
+
|
|
38
|
+
type AttachmentCandidate = {
|
|
39
|
+
name: string;
|
|
40
|
+
mimeType?: string | null;
|
|
41
|
+
sizeBytes: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function normalizeFileName(name: string): string {
|
|
45
|
+
const trimmed = name.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return "attachment";
|
|
48
|
+
}
|
|
49
|
+
const basename = trimmed.split(/[\\/]/).pop()?.trim() || "attachment";
|
|
50
|
+
return basename || "attachment";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeMimeType(mimeType: string | null | undefined): string {
|
|
54
|
+
return typeof mimeType === "string" ? mimeType.trim().toLowerCase() : "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extensionFromName(name: string): string {
|
|
58
|
+
const index = name.lastIndexOf(".");
|
|
59
|
+
if (index < 0 || index === name.length - 1) {
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
return name.slice(index + 1).toLowerCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function inferMimeType(name: string, fallback: ChatAttachment["kind"]): string {
|
|
66
|
+
const ext = extensionFromName(name);
|
|
67
|
+
const mapped = EXTENSION_TO_MIME[ext];
|
|
68
|
+
if (mapped) {
|
|
69
|
+
return mapped;
|
|
70
|
+
}
|
|
71
|
+
if (fallback === "image") {
|
|
72
|
+
return "image/png";
|
|
73
|
+
}
|
|
74
|
+
if (fallback === "pdf") {
|
|
75
|
+
return "application/pdf";
|
|
76
|
+
}
|
|
77
|
+
return "text/plain";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getAttachmentProviderForConnection(
|
|
81
|
+
connection: AttachmentConnection,
|
|
82
|
+
): ChatAttachment["provider"] | null {
|
|
83
|
+
if (!connection || typeof connection.kind !== "string") {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (connection.kind === "openai_compatible") {
|
|
87
|
+
return "openai";
|
|
88
|
+
}
|
|
89
|
+
if (connection.kind !== "builtin" || typeof connection.provider !== "string") {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (connection.provider === "openai") {
|
|
93
|
+
return "openai";
|
|
94
|
+
}
|
|
95
|
+
if (connection.provider === "anthropic") {
|
|
96
|
+
return "anthropic";
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function detectAttachmentKind(
|
|
102
|
+
candidate: Pick<AttachmentCandidate, "name" | "mimeType">,
|
|
103
|
+
): ChatAttachment["kind"] | null {
|
|
104
|
+
const name = normalizeFileName(candidate.name);
|
|
105
|
+
const extension = extensionFromName(name);
|
|
106
|
+
const mimeType = normalizeMimeType(candidate.mimeType);
|
|
107
|
+
|
|
108
|
+
if (mimeType.startsWith("image/") || IMAGE_EXTENSIONS.has(extension)) {
|
|
109
|
+
return "image";
|
|
110
|
+
}
|
|
111
|
+
if (mimeType === "application/pdf" || extension === "pdf") {
|
|
112
|
+
return "pdf";
|
|
113
|
+
}
|
|
114
|
+
if (DOCUMENT_MIME_TYPES.has(mimeType) || DOCUMENT_EXTENSIONS.has(extension)) {
|
|
115
|
+
return "document";
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function validateAttachmentCandidate(candidate: AttachmentCandidate): {
|
|
121
|
+
ok: true;
|
|
122
|
+
kind: ChatAttachment["kind"];
|
|
123
|
+
name: string;
|
|
124
|
+
mimeType: string;
|
|
125
|
+
sizeBytes: number;
|
|
126
|
+
} | {
|
|
127
|
+
ok: false;
|
|
128
|
+
error: string;
|
|
129
|
+
} {
|
|
130
|
+
const name = normalizeFileName(candidate.name);
|
|
131
|
+
if (!Number.isFinite(candidate.sizeBytes) || candidate.sizeBytes <= 0) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: `${name} is empty.`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (candidate.sizeBytes > MAX_ATTACHMENT_SIZE_BYTES) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
error: `${name} is larger than 25 MB.`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const kind = detectAttachmentKind(candidate);
|
|
145
|
+
if (!kind) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: `${name} is not a supported file type.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const normalizedMimeType = normalizeMimeType(candidate.mimeType) || inferMimeType(name, kind);
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
kind,
|
|
156
|
+
name,
|
|
157
|
+
mimeType: normalizedMimeType,
|
|
158
|
+
sizeBytes: candidate.sizeBytes,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function normalizeChatAttachments(input: unknown): ChatAttachment[] {
|
|
163
|
+
if (!Array.isArray(input)) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const normalized: ChatAttachment[] = [];
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
|
|
170
|
+
for (const item of input) {
|
|
171
|
+
if (!item || typeof item !== "object") {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const record = item as {
|
|
175
|
+
provider?: unknown;
|
|
176
|
+
providerFileId?: unknown;
|
|
177
|
+
kind?: unknown;
|
|
178
|
+
name?: unknown;
|
|
179
|
+
mimeType?: unknown;
|
|
180
|
+
sizeBytes?: unknown;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const provider =
|
|
184
|
+
record.provider === "openai" || record.provider === "anthropic"
|
|
185
|
+
? record.provider
|
|
186
|
+
: null;
|
|
187
|
+
const providerFileId =
|
|
188
|
+
typeof record.providerFileId === "string" ? record.providerFileId.trim() : "";
|
|
189
|
+
const kind =
|
|
190
|
+
record.kind === "image" || record.kind === "pdf" || record.kind === "document"
|
|
191
|
+
? record.kind
|
|
192
|
+
: null;
|
|
193
|
+
const name = typeof record.name === "string" ? normalizeFileName(record.name) : "";
|
|
194
|
+
const mimeType = normalizeMimeType(
|
|
195
|
+
typeof record.mimeType === "string" ? record.mimeType : "",
|
|
196
|
+
);
|
|
197
|
+
const sizeBytes =
|
|
198
|
+
typeof record.sizeBytes === "number" && Number.isFinite(record.sizeBytes) && record.sizeBytes > 0
|
|
199
|
+
? record.sizeBytes
|
|
200
|
+
: 0;
|
|
201
|
+
|
|
202
|
+
if (!provider || !providerFileId || !kind || !name || !mimeType || sizeBytes <= 0) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const uniqueKey = `${provider}:${providerFileId}`;
|
|
207
|
+
if (seen.has(uniqueKey)) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
seen.add(uniqueKey);
|
|
211
|
+
normalized.push({
|
|
212
|
+
provider,
|
|
213
|
+
providerFileId,
|
|
214
|
+
kind,
|
|
215
|
+
name,
|
|
216
|
+
mimeType,
|
|
217
|
+
sizeBytes,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return normalized;
|
|
222
|
+
}
|