iris-chatbot 5.3.0 → 5.4.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/next-env.d.ts +1 -1
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/src/app/api/chat/route.ts +321 -23
- package/template/src/app/api/chat/uploads/route.ts +220 -0
- package/template/src/app/globals.css +105 -3
- package/template/src/components/ChatView.tsx +210 -5
- package/template/src/components/Composer.tsx +93 -3
- package/template/src/components/MessageCard.tsx +28 -1
- package/template/src/lib/attachments.ts +222 -0
- package/template/src/lib/data.ts +4 -1
- package/template/src/lib/types.ts +11 -0
|
@@ -137,8 +137,7 @@ button:focus-visible {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
.message-card.user .message-content {
|
|
140
|
-
display:
|
|
141
|
-
align-items: center;
|
|
140
|
+
display: block;
|
|
142
141
|
min-height: 1.4em;
|
|
143
142
|
line-height: 1.45;
|
|
144
143
|
}
|
|
@@ -147,6 +146,43 @@ button:focus-visible {
|
|
|
147
146
|
margin: 0;
|
|
148
147
|
}
|
|
149
148
|
|
|
149
|
+
.user-message-content {
|
|
150
|
+
display: flex;
|
|
151
|
+
flex-direction: column;
|
|
152
|
+
gap: 8px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.user-attachment-row {
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-wrap: wrap;
|
|
158
|
+
gap: 6px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.user-attachment-pill {
|
|
162
|
+
display: inline-flex;
|
|
163
|
+
align-items: center;
|
|
164
|
+
gap: 6px;
|
|
165
|
+
max-width: 240px;
|
|
166
|
+
border-radius: 999px;
|
|
167
|
+
border: 1px solid rgba(255, 255, 255, 0.34);
|
|
168
|
+
background: rgba(255, 255, 255, 0.15);
|
|
169
|
+
color: #ffffff;
|
|
170
|
+
padding: 4px 9px;
|
|
171
|
+
font-size: 11px;
|
|
172
|
+
line-height: 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.user-attachment-kind {
|
|
176
|
+
font-weight: 700;
|
|
177
|
+
opacity: 0.9;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.user-attachment-title {
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
text-overflow: ellipsis;
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
}
|
|
185
|
+
|
|
150
186
|
.message-row.user .message-content> :first-child,
|
|
151
187
|
.message-row.user .message-content> :last-child {
|
|
152
188
|
margin-top: 0;
|
|
@@ -917,7 +953,7 @@ button:focus-visible {
|
|
|
917
953
|
line-height: 1.4;
|
|
918
954
|
padding-top: 7px;
|
|
919
955
|
padding-bottom: 9px;
|
|
920
|
-
padding-left:
|
|
956
|
+
padding-left: 0;
|
|
921
957
|
/* One line by default; height grows with content in Composer.tsx */
|
|
922
958
|
min-height: calc(1.4em + 7px + 9px);
|
|
923
959
|
}
|
|
@@ -956,6 +992,23 @@ button:focus-visible {
|
|
|
956
992
|
border-color: transparent;
|
|
957
993
|
}
|
|
958
994
|
|
|
995
|
+
.send-button:disabled {
|
|
996
|
+
opacity: 0.65;
|
|
997
|
+
cursor: not-allowed;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.composer-attach-button {
|
|
1001
|
+
background: transparent;
|
|
1002
|
+
border-color: transparent;
|
|
1003
|
+
color: var(--text-secondary);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.composer-attach-button:hover {
|
|
1007
|
+
background: transparent;
|
|
1008
|
+
color: var(--text-primary);
|
|
1009
|
+
border-color: transparent;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
959
1012
|
.mic-button {
|
|
960
1013
|
background: transparent;
|
|
961
1014
|
color: var(--text-secondary);
|
|
@@ -1223,6 +1276,55 @@ button:focus-visible {
|
|
|
1223
1276
|
gap: 0;
|
|
1224
1277
|
}
|
|
1225
1278
|
|
|
1279
|
+
.composer-attachments {
|
|
1280
|
+
display: flex;
|
|
1281
|
+
flex-wrap: wrap;
|
|
1282
|
+
gap: 8px;
|
|
1283
|
+
padding: 0 2px 10px;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
.composer-attachment-chip {
|
|
1287
|
+
display: inline-flex;
|
|
1288
|
+
align-items: center;
|
|
1289
|
+
gap: 6px;
|
|
1290
|
+
max-width: 260px;
|
|
1291
|
+
border: 1px solid var(--border);
|
|
1292
|
+
background: var(--panel-2);
|
|
1293
|
+
border-radius: 999px;
|
|
1294
|
+
padding: 4px 8px;
|
|
1295
|
+
font-size: 12px;
|
|
1296
|
+
color: var(--text-secondary);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
.composer-attachment-icon {
|
|
1300
|
+
display: inline-flex;
|
|
1301
|
+
align-items: center;
|
|
1302
|
+
justify-content: center;
|
|
1303
|
+
color: var(--text-muted);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
.composer-attachment-name {
|
|
1307
|
+
overflow: hidden;
|
|
1308
|
+
text-overflow: ellipsis;
|
|
1309
|
+
white-space: nowrap;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
.composer-attachment-remove {
|
|
1313
|
+
display: inline-flex;
|
|
1314
|
+
align-items: center;
|
|
1315
|
+
justify-content: center;
|
|
1316
|
+
border: none;
|
|
1317
|
+
background: transparent;
|
|
1318
|
+
color: var(--text-muted);
|
|
1319
|
+
width: 16px;
|
|
1320
|
+
height: 16px;
|
|
1321
|
+
border-radius: 999px;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
.composer-attachment-remove:hover {
|
|
1325
|
+
color: var(--text-primary);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1226
1328
|
.quoted-context-container {
|
|
1227
1329
|
display: flex;
|
|
1228
1330
|
align-items: flex-start;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
|
4
|
+
import { nanoid } from "nanoid";
|
|
4
5
|
import type {
|
|
6
|
+
ChatAttachment,
|
|
5
7
|
ChatCitationSource,
|
|
6
8
|
ModelConnection,
|
|
7
9
|
ChatStreamChunk,
|
|
@@ -17,6 +19,12 @@ import {
|
|
|
17
19
|
createThreadFromMessage,
|
|
18
20
|
deleteThread as deleteThreadById,
|
|
19
21
|
} from "../lib/data";
|
|
22
|
+
import {
|
|
23
|
+
getAttachmentProviderForConnection,
|
|
24
|
+
MAX_ATTACHMENTS_PER_MESSAGE,
|
|
25
|
+
normalizeChatAttachments,
|
|
26
|
+
validateAttachmentCandidate,
|
|
27
|
+
} from "../lib/attachments";
|
|
20
28
|
import { buildPath, embedSourcesInContent, isLikelyGreeting, splitContentAndSources } from "../lib/utils";
|
|
21
29
|
import { db } from "../lib/db";
|
|
22
30
|
import {
|
|
@@ -355,6 +363,11 @@ function isLikelyDraftEditFollowup(input: string): boolean {
|
|
|
355
363
|
|
|
356
364
|
type MicMode = "off" | "dictation";
|
|
357
365
|
type MicState = "idle" | "listening" | "processing";
|
|
366
|
+
type PendingAttachment = {
|
|
367
|
+
id: string;
|
|
368
|
+
file: File;
|
|
369
|
+
kind: ChatAttachment["kind"];
|
|
370
|
+
};
|
|
358
371
|
|
|
359
372
|
export default function ChatView({
|
|
360
373
|
thread,
|
|
@@ -389,6 +402,8 @@ export default function ChatView({
|
|
|
389
402
|
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
|
390
403
|
const [micMode, setMicMode] = useState<MicMode>("off");
|
|
391
404
|
const [micState, setMicState] = useState<MicState>("idle");
|
|
405
|
+
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
|
406
|
+
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
|
|
392
407
|
const [quotedContext, setQuotedContext] = useState<string | null>(null);
|
|
393
408
|
const [askButtonPos, setAskButtonPos] = useState<{ top: number; left: number } | null>(null);
|
|
394
409
|
const [pendingSelection, setPendingSelection] = useState<string | null>(null);
|
|
@@ -1060,6 +1075,78 @@ export default function ChatView({
|
|
|
1060
1075
|
setQuotedContext(null);
|
|
1061
1076
|
}, []);
|
|
1062
1077
|
|
|
1078
|
+
const pendingAttachmentChips = useMemo(
|
|
1079
|
+
() =>
|
|
1080
|
+
pendingAttachments.map((attachment) => ({
|
|
1081
|
+
id: attachment.id,
|
|
1082
|
+
name: attachment.file.name,
|
|
1083
|
+
kind: attachment.kind,
|
|
1084
|
+
})),
|
|
1085
|
+
[pendingAttachments],
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
const removePendingAttachment = useCallback((attachmentId: string) => {
|
|
1089
|
+
setPendingAttachments((current) =>
|
|
1090
|
+
current.filter((attachment) => attachment.id !== attachmentId),
|
|
1091
|
+
);
|
|
1092
|
+
}, []);
|
|
1093
|
+
|
|
1094
|
+
const attachFilesToComposer = useCallback((files: File[]) => {
|
|
1095
|
+
if (files.length === 0) {
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const nextAttachments = [...pendingAttachments];
|
|
1100
|
+
const dedupe = new Set(
|
|
1101
|
+
nextAttachments.map(
|
|
1102
|
+
(attachment) =>
|
|
1103
|
+
`${attachment.file.name}:${attachment.file.size}:${attachment.file.lastModified}`,
|
|
1104
|
+
),
|
|
1105
|
+
);
|
|
1106
|
+
let nextError: string | null = null;
|
|
1107
|
+
let addedAny = false;
|
|
1108
|
+
for (const file of files) {
|
|
1109
|
+
if (nextAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE) {
|
|
1110
|
+
nextError = `You can upload up to ${MAX_ATTACHMENTS_PER_MESSAGE} files per message.`;
|
|
1111
|
+
break;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const validation = validateAttachmentCandidate({
|
|
1115
|
+
name: file.name,
|
|
1116
|
+
mimeType: file.type,
|
|
1117
|
+
sizeBytes: file.size,
|
|
1118
|
+
});
|
|
1119
|
+
if (!validation.ok) {
|
|
1120
|
+
nextError = nextError ?? validation.error;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
|
1125
|
+
if (dedupe.has(key)) {
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
dedupe.add(key);
|
|
1129
|
+
nextAttachments.push({
|
|
1130
|
+
id: nanoid(),
|
|
1131
|
+
file,
|
|
1132
|
+
kind: validation.kind,
|
|
1133
|
+
});
|
|
1134
|
+
addedAny = true;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (addedAny) {
|
|
1138
|
+
setPendingAttachments(nextAttachments);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (nextError) {
|
|
1142
|
+
setError(nextError);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (addedAny) {
|
|
1146
|
+
setError(null);
|
|
1147
|
+
}
|
|
1148
|
+
}, [pendingAttachments]);
|
|
1149
|
+
|
|
1063
1150
|
useEffect(() => {
|
|
1064
1151
|
const onMouseUp = () => {
|
|
1065
1152
|
// Small delay so the selection is finalized
|
|
@@ -1094,7 +1181,10 @@ export default function ChatView({
|
|
|
1094
1181
|
if (!resolvedThread) return;
|
|
1095
1182
|
const trimmed = (overrideInput ?? input).trim();
|
|
1096
1183
|
const hasQuoted = Boolean(quotedContext);
|
|
1097
|
-
|
|
1184
|
+
const pendingComposerAttachments = pendingAttachments;
|
|
1185
|
+
const hasPendingAttachments = pendingComposerAttachments.length > 0;
|
|
1186
|
+
if (!trimmed && !hasQuoted && !hasPendingAttachments) return;
|
|
1187
|
+
if (isUploadingAttachments) return;
|
|
1098
1188
|
let draftEditContextForRequest: PendingDraftContext | null = null;
|
|
1099
1189
|
const sendTriggeredByMic =
|
|
1100
1190
|
typeof overrideInput === "string" || micModeRef.current !== "off";
|
|
@@ -1131,6 +1221,34 @@ export default function ChatView({
|
|
|
1131
1221
|
return;
|
|
1132
1222
|
}
|
|
1133
1223
|
|
|
1224
|
+
const connectionAttachmentProvider = getAttachmentProviderForConnection(connectionPayload);
|
|
1225
|
+
const currentPathMessages = buildPath(resolvedThread.headMessageId, messageMap);
|
|
1226
|
+
const existingAttachmentProviders = new Set<ChatAttachment["provider"]>();
|
|
1227
|
+
for (const message of currentPathMessages) {
|
|
1228
|
+
const attachments = normalizeChatAttachments(message.attachments);
|
|
1229
|
+
for (const attachment of attachments) {
|
|
1230
|
+
existingAttachmentProviders.add(attachment.provider);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (existingAttachmentProviders.size > 1) {
|
|
1234
|
+
setError("This thread has mixed attachment providers. Start a new chat to continue.");
|
|
1235
|
+
stopMicAfterValidationFailure();
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const existingProvider = [...existingAttachmentProviders][0] ?? null;
|
|
1239
|
+
if (existingProvider && connectionAttachmentProvider !== existingProvider) {
|
|
1240
|
+
setError(
|
|
1241
|
+
`This thread has ${existingProvider} attachments. Switch back to that provider or start a new chat.`,
|
|
1242
|
+
);
|
|
1243
|
+
stopMicAfterValidationFailure();
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (hasPendingAttachments && !connectionAttachmentProvider) {
|
|
1247
|
+
setError("Attachments are only supported on OpenAI/OpenAI-compatible and Anthropic connections.");
|
|
1248
|
+
stopMicAfterValidationFailure();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1134
1252
|
if (!model) {
|
|
1135
1253
|
setError("Select a model before sending a message.");
|
|
1136
1254
|
stopMicAfterValidationFailure();
|
|
@@ -1187,6 +1305,50 @@ export default function ChatView({
|
|
|
1187
1305
|
return;
|
|
1188
1306
|
}
|
|
1189
1307
|
|
|
1308
|
+
let uploadedAttachments: ChatAttachment[] = [];
|
|
1309
|
+
if (pendingComposerAttachments.length > 0) {
|
|
1310
|
+
setIsUploadingAttachments(true);
|
|
1311
|
+
try {
|
|
1312
|
+
const formData = new FormData();
|
|
1313
|
+
formData.set("connection", JSON.stringify(connectionPayload));
|
|
1314
|
+
for (const attachment of pendingComposerAttachments) {
|
|
1315
|
+
formData.append("files", attachment.file, attachment.file.name);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const uploadResponse = await fetch("/api/chat/uploads", {
|
|
1319
|
+
method: "POST",
|
|
1320
|
+
body: formData,
|
|
1321
|
+
});
|
|
1322
|
+
const uploadPayload = (await uploadResponse.json().catch(() => ({}))) as {
|
|
1323
|
+
error?: string;
|
|
1324
|
+
attachments?: unknown;
|
|
1325
|
+
};
|
|
1326
|
+
if (!uploadResponse.ok) {
|
|
1327
|
+
throw new Error(uploadPayload.error || "Failed to upload attachments.");
|
|
1328
|
+
}
|
|
1329
|
+
uploadedAttachments = normalizeChatAttachments(uploadPayload.attachments);
|
|
1330
|
+
if (uploadedAttachments.length !== pendingComposerAttachments.length) {
|
|
1331
|
+
throw new Error("One or more attachments were rejected during upload.");
|
|
1332
|
+
}
|
|
1333
|
+
if (
|
|
1334
|
+
connectionAttachmentProvider &&
|
|
1335
|
+
uploadedAttachments.some(
|
|
1336
|
+
(attachment) => attachment.provider !== connectionAttachmentProvider,
|
|
1337
|
+
)
|
|
1338
|
+
) {
|
|
1339
|
+
throw new Error("Uploaded attachment provider does not match the selected connection.");
|
|
1340
|
+
}
|
|
1341
|
+
} catch (uploadError) {
|
|
1342
|
+
const uploadMessage =
|
|
1343
|
+
uploadError instanceof Error ? uploadError.message : "Failed to upload attachments.";
|
|
1344
|
+
setError(uploadMessage);
|
|
1345
|
+
stopMicAfterValidationFailure();
|
|
1346
|
+
return;
|
|
1347
|
+
} finally {
|
|
1348
|
+
setIsUploadingAttachments(false);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1190
1352
|
inflightThreadIdsRef.current.set(
|
|
1191
1353
|
resolvedThread.id,
|
|
1192
1354
|
(inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0) + 1,
|
|
@@ -1214,12 +1376,21 @@ export default function ChatView({
|
|
|
1214
1376
|
const { userMessage, assistantMessage } = await appendUserAndAssistant({
|
|
1215
1377
|
thread: resolvedThread,
|
|
1216
1378
|
content: contentToSend,
|
|
1379
|
+
attachments: uploadedAttachments,
|
|
1217
1380
|
provider: connection.kind === "builtin" ? connection.provider ?? connection.id : connection.id,
|
|
1218
1381
|
model,
|
|
1219
1382
|
});
|
|
1383
|
+
if (pendingComposerAttachments.length > 0) {
|
|
1384
|
+
const sentAttachmentIds = new Set(
|
|
1385
|
+
pendingComposerAttachments.map((attachment) => attachment.id),
|
|
1386
|
+
);
|
|
1387
|
+
setPendingAttachments((current) =>
|
|
1388
|
+
current.filter((attachment) => !sentAttachmentIds.has(attachment.id)),
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1220
1391
|
|
|
1221
1392
|
if (isFirstMessage) {
|
|
1222
|
-
if (isLikelyGreeting(trimmed)) {
|
|
1393
|
+
if (!contentToSend.trim() || isLikelyGreeting(trimmed)) {
|
|
1223
1394
|
await db.threads.update(resolvedThread.id, { title: "New chat" });
|
|
1224
1395
|
} else {
|
|
1225
1396
|
void (async () => {
|
|
@@ -1292,7 +1463,11 @@ export default function ChatView({
|
|
|
1292
1463
|
map.set(userMessage.id, userMessage);
|
|
1293
1464
|
map.set(assistantMessage.id, assistantMessage);
|
|
1294
1465
|
const history = buildPath(userMessage.id, map);
|
|
1295
|
-
const payloadMessages: Array<{
|
|
1466
|
+
const payloadMessages: Array<{
|
|
1467
|
+
role: "system" | "user" | "assistant";
|
|
1468
|
+
content: string;
|
|
1469
|
+
attachments?: ChatAttachment[];
|
|
1470
|
+
}> = [];
|
|
1296
1471
|
let memoryPayload: MemoryContextPayload | undefined;
|
|
1297
1472
|
|
|
1298
1473
|
if (memoryEnabled) {
|
|
@@ -1379,6 +1554,15 @@ export default function ChatView({
|
|
|
1379
1554
|
}
|
|
1380
1555
|
}
|
|
1381
1556
|
|
|
1557
|
+
const attachments =
|
|
1558
|
+
message.role === "user"
|
|
1559
|
+
? normalizeChatAttachments(message.attachments)
|
|
1560
|
+
: [];
|
|
1561
|
+
|
|
1562
|
+
if (attachments.length > 0) {
|
|
1563
|
+
return { role: message.role, content, attachments };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1382
1566
|
return { role: message.role, content };
|
|
1383
1567
|
}),
|
|
1384
1568
|
);
|
|
@@ -1776,6 +1960,8 @@ export default function ChatView({
|
|
|
1776
1960
|
startMicRecognitionSession,
|
|
1777
1961
|
stopMicMode,
|
|
1778
1962
|
quotedContext,
|
|
1963
|
+
pendingAttachments,
|
|
1964
|
+
isUploadingAttachments,
|
|
1779
1965
|
messageMap,
|
|
1780
1966
|
toolEventsByMessage,
|
|
1781
1967
|
]);
|
|
@@ -1784,6 +1970,11 @@ export default function ChatView({
|
|
|
1784
1970
|
handleSendRef.current = handleSend;
|
|
1785
1971
|
}, [handleSend]);
|
|
1786
1972
|
|
|
1973
|
+
useEffect(() => {
|
|
1974
|
+
setPendingAttachments([]);
|
|
1975
|
+
setIsUploadingAttachments(false);
|
|
1976
|
+
}, [thread?.id]);
|
|
1977
|
+
|
|
1787
1978
|
useEffect(() => {
|
|
1788
1979
|
const pendingThreadId = pendingSendScrollThreadIdRef.current;
|
|
1789
1980
|
if (!pendingThreadId || pendingThreadId !== thread?.id) {
|
|
@@ -1910,9 +2101,16 @@ export default function ChatView({
|
|
|
1910
2101
|
showStopWhenStreaming={!isAwaitingDraftApproval}
|
|
1911
2102
|
onMicToggle={handleMicToggle}
|
|
1912
2103
|
micState={micState}
|
|
1913
|
-
micDisabled={
|
|
2104
|
+
micDisabled={
|
|
2105
|
+
(Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval) ||
|
|
2106
|
+
isUploadingAttachments
|
|
2107
|
+
}
|
|
1914
2108
|
quotedContext={quotedContext}
|
|
1915
2109
|
onClearQuotedContext={handleClearQuotedContext}
|
|
2110
|
+
pendingAttachments={pendingAttachmentChips}
|
|
2111
|
+
onAttachFiles={attachFilesToComposer}
|
|
2112
|
+
onRemovePendingAttachment={removePendingAttachment}
|
|
2113
|
+
isUploadingAttachments={isUploadingAttachments}
|
|
1916
2114
|
/>
|
|
1917
2115
|
</div>
|
|
1918
2116
|
</div>
|
|
@@ -1976,9 +2174,16 @@ export default function ChatView({
|
|
|
1976
2174
|
showStopWhenStreaming={!isAwaitingDraftApproval}
|
|
1977
2175
|
onMicToggle={handleMicToggle}
|
|
1978
2176
|
micState={micState}
|
|
1979
|
-
micDisabled={
|
|
2177
|
+
micDisabled={
|
|
2178
|
+
(Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval) ||
|
|
2179
|
+
isUploadingAttachments
|
|
2180
|
+
}
|
|
1980
2181
|
quotedContext={quotedContext}
|
|
1981
2182
|
onClearQuotedContext={handleClearQuotedContext}
|
|
2183
|
+
pendingAttachments={pendingAttachmentChips}
|
|
2184
|
+
onAttachFiles={attachFilesToComposer}
|
|
2185
|
+
onRemovePendingAttachment={removePendingAttachment}
|
|
2186
|
+
isUploadingAttachments={isUploadingAttachments}
|
|
1982
2187
|
/>
|
|
1983
2188
|
</div>
|
|
1984
2189
|
</div>
|
|
@@ -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>
|
|
@@ -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;
|
|
@@ -1279,6 +1280,10 @@ function MessageCard({
|
|
|
1279
1280
|
() => splitContentAndSources(message.content || ""),
|
|
1280
1281
|
[message.content],
|
|
1281
1282
|
);
|
|
1283
|
+
const userAttachments = useMemo(
|
|
1284
|
+
() => (message.role === "user" ? normalizeChatAttachments(message.attachments) : []),
|
|
1285
|
+
[message.role, message.attachments],
|
|
1286
|
+
);
|
|
1282
1287
|
const sourcesOrderedByCitation = useMemo(
|
|
1283
1288
|
() => reorderSourcesByCitationOrder(messageTextContent, messageSources),
|
|
1284
1289
|
[messageTextContent, messageSources],
|
|
@@ -1415,7 +1420,29 @@ function MessageCard({
|
|
|
1415
1420
|
</ReactMarkdown>
|
|
1416
1421
|
)
|
|
1417
1422
|
) : (
|
|
1418
|
-
<
|
|
1423
|
+
<div className="user-message-content">
|
|
1424
|
+
{userAttachments.length > 0 ? (
|
|
1425
|
+
<div className="user-attachment-row" aria-label="Attachments">
|
|
1426
|
+
{userAttachments.map((attachment, index) => (
|
|
1427
|
+
<span
|
|
1428
|
+
key={`${attachment.providerFileId}-${index}`}
|
|
1429
|
+
className="user-attachment-pill"
|
|
1430
|
+
title={attachment.name}
|
|
1431
|
+
>
|
|
1432
|
+
<span className="user-attachment-kind">
|
|
1433
|
+
{attachment.kind === "image"
|
|
1434
|
+
? "IMG"
|
|
1435
|
+
: attachment.kind === "pdf"
|
|
1436
|
+
? "PDF"
|
|
1437
|
+
: "DOC"}
|
|
1438
|
+
</span>
|
|
1439
|
+
<span className="user-attachment-title">{attachment.name}</span>
|
|
1440
|
+
</span>
|
|
1441
|
+
))}
|
|
1442
|
+
</div>
|
|
1443
|
+
) : null}
|
|
1444
|
+
{messageTextContent ? <p>{messageTextContent}</p> : null}
|
|
1445
|
+
</div>
|
|
1419
1446
|
)}
|
|
1420
1447
|
|
|
1421
1448
|
{message.role === "assistant" && !assistantCollapsed && sourcesOrderedByCitation.length > 0 ? (
|