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.
@@ -1,7 +1,20 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
4
- import { ArrowUp, CornerDownRight, Loader2, Mic, Square, X } from "lucide-react";
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 ${hasText || quotedContext ? "active" : ""}`}
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 className={`${isAssistant ? "assistant-card" : "message-card user"} group`}>
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
- <p>{messageTextContent}</p>
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
- <div
1571
- className={`message-actions ${isAssistant ? "assistant opacity-0 transition-opacity duration-150 hover:opacity-100" : "user"}`}
1572
- >
1573
- {isAssistant ? (
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 justify-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition ${canAddThread
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
- if (!canAddThread) return;
1581
- await onAddThread(message);
1582
- setThreadAdded(true);
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
- {threadAdded ? (
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
- ) : null}
1597
- <button
1598
- 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"
1599
- }`}
1600
- onClick={async () => {
1601
- await navigator.clipboard.writeText(messageTextContent);
1602
- setCopied(true);
1603
- window.setTimeout(() => setCopied(false), 1200);
1604
- }}
1605
- >
1606
- {copied ? (
1607
- <Check className="h-4 w-4 text-[var(--accent)]" />
1608
- ) : (
1609
- <Copy className="h-4 w-4" />
1610
- )}
1611
- </button>
1612
- {canEditThreads ? (
1613
- <button
1614
- className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${threadEditMode
1615
- ? "border-[var(--accent-ring)] text-[var(--text-primary)]"
1616
- : "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
1617
- }`}
1618
- onClick={() => setThreadEditMode((prev) => !prev)}
1619
- aria-label="Edit threads"
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
+ }