iris-chatbot 5.4.0 → 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/next-env.d.ts +1 -1
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/src/app/globals.css +69 -0
- package/template/src/components/ChatView.tsx +148 -16
- package/template/src/components/MessageCard.tsx +177 -49
- package/template/src/lib/data.ts +3 -2
package/package.json
CHANGED
package/template/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/
|
|
3
|
+
import "./.next/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iris",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "iris",
|
|
9
|
-
"version": "5.
|
|
9
|
+
"version": "5.5.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.72.1",
|
|
12
12
|
"clsx": "^2.1.1",
|
package/template/package.json
CHANGED
|
@@ -152,6 +152,75 @@ button:focus-visible {
|
|
|
152
152
|
gap: 8px;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
.user-edit-shell {
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
gap: 14px;
|
|
159
|
+
width: 100%;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.user-edit-textarea {
|
|
163
|
+
width: 100%;
|
|
164
|
+
min-height: 110px;
|
|
165
|
+
border: none;
|
|
166
|
+
background: transparent;
|
|
167
|
+
color: inherit;
|
|
168
|
+
line-height: 1.45;
|
|
169
|
+
resize: none;
|
|
170
|
+
outline: none;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.user-edit-textarea:focus,
|
|
174
|
+
.user-edit-textarea:focus-visible {
|
|
175
|
+
outline: none;
|
|
176
|
+
border-color: transparent;
|
|
177
|
+
box-shadow: none;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.user-edit-textarea::placeholder {
|
|
181
|
+
color: color-mix(in srgb, currentColor 65%, transparent);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.user-edit-actions {
|
|
185
|
+
display: flex;
|
|
186
|
+
justify-content: flex-end;
|
|
187
|
+
gap: 10px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.user-edit-cancel,
|
|
191
|
+
.user-edit-send {
|
|
192
|
+
height: 40px;
|
|
193
|
+
border-radius: 999px;
|
|
194
|
+
padding: 0 22px;
|
|
195
|
+
font-size: 14px;
|
|
196
|
+
border: 1px solid transparent;
|
|
197
|
+
transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.user-edit-cancel {
|
|
201
|
+
background: rgba(22, 24, 28, 0.82);
|
|
202
|
+
color: #f2f4f8;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.user-edit-cancel:hover {
|
|
206
|
+
background: rgba(22, 24, 28, 0.95);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.user-edit-send {
|
|
210
|
+
background: #f2f2f2;
|
|
211
|
+
color: #101010;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.user-edit-send:hover {
|
|
215
|
+
background: #ffffff;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.user-edit-cancel:disabled,
|
|
219
|
+
.user-edit-send:disabled {
|
|
220
|
+
opacity: 0.65;
|
|
221
|
+
cursor: not-allowed;
|
|
222
|
+
}
|
|
223
|
+
|
|
155
224
|
.user-attachment-row {
|
|
156
225
|
display: flex;
|
|
157
226
|
flex-wrap: wrap;
|
|
@@ -369,6 +369,13 @@ type PendingAttachment = {
|
|
|
369
369
|
kind: ChatAttachment["kind"];
|
|
370
370
|
};
|
|
371
371
|
|
|
372
|
+
type HandleSendOptions = {
|
|
373
|
+
parentMessageId?: string | null;
|
|
374
|
+
historyHeadMessageId?: string | null;
|
|
375
|
+
preUploadedAttachments?: ChatAttachment[];
|
|
376
|
+
suppressComposerReset?: boolean;
|
|
377
|
+
};
|
|
378
|
+
|
|
372
379
|
export default function ChatView({
|
|
373
380
|
thread,
|
|
374
381
|
messages,
|
|
@@ -1175,19 +1182,23 @@ export default function ChatView({
|
|
|
1175
1182
|
};
|
|
1176
1183
|
}, [handleTextSelection]);
|
|
1177
1184
|
|
|
1178
|
-
const handleSend = useCallback(async (overrideInput?: string) => {
|
|
1185
|
+
const handleSend = useCallback(async (overrideInput?: string, options?: HandleSendOptions) => {
|
|
1179
1186
|
const resolvedThread =
|
|
1180
1187
|
thread || (activeThreadId ? await db.threads.get(activeThreadId) : null);
|
|
1181
1188
|
if (!resolvedThread) return;
|
|
1182
1189
|
const trimmed = (overrideInput ?? input).trim();
|
|
1183
|
-
const
|
|
1184
|
-
const
|
|
1190
|
+
const resetComposer = options?.suppressComposerReset !== true;
|
|
1191
|
+
const hasQuoted = resetComposer && Boolean(quotedContext);
|
|
1192
|
+
const pendingComposerAttachments = options?.preUploadedAttachments ? [] : pendingAttachments;
|
|
1193
|
+
const preUploadedAttachments = normalizeChatAttachments(options?.preUploadedAttachments);
|
|
1194
|
+
const hasPreUploadedAttachments = preUploadedAttachments.length > 0;
|
|
1185
1195
|
const hasPendingAttachments = pendingComposerAttachments.length > 0;
|
|
1186
|
-
if (!trimmed && !hasQuoted && !hasPendingAttachments) return;
|
|
1187
|
-
if (isUploadingAttachments) return;
|
|
1196
|
+
if (!trimmed && !hasQuoted && !hasPendingAttachments && !hasPreUploadedAttachments) return;
|
|
1197
|
+
if (isUploadingAttachments && hasPendingAttachments) return;
|
|
1188
1198
|
let draftEditContextForRequest: PendingDraftContext | null = null;
|
|
1189
1199
|
const sendTriggeredByMic =
|
|
1190
|
-
|
|
1200
|
+
!options &&
|
|
1201
|
+
(typeof overrideInput === "string" || micModeRef.current !== "off");
|
|
1191
1202
|
const stopMicAfterValidationFailure = () => {
|
|
1192
1203
|
if (!sendTriggeredByMic) {
|
|
1193
1204
|
return;
|
|
@@ -1222,7 +1233,10 @@ export default function ChatView({
|
|
|
1222
1233
|
}
|
|
1223
1234
|
|
|
1224
1235
|
const connectionAttachmentProvider = getAttachmentProviderForConnection(connectionPayload);
|
|
1225
|
-
const currentPathMessages = buildPath(
|
|
1236
|
+
const currentPathMessages = buildPath(
|
|
1237
|
+
options?.historyHeadMessageId ?? resolvedThread.headMessageId,
|
|
1238
|
+
messageMap,
|
|
1239
|
+
);
|
|
1226
1240
|
const existingAttachmentProviders = new Set<ChatAttachment["provider"]>();
|
|
1227
1241
|
for (const message of currentPathMessages) {
|
|
1228
1242
|
const attachments = normalizeChatAttachments(message.attachments);
|
|
@@ -1243,7 +1257,7 @@ export default function ChatView({
|
|
|
1243
1257
|
stopMicAfterValidationFailure();
|
|
1244
1258
|
return;
|
|
1245
1259
|
}
|
|
1246
|
-
if (hasPendingAttachments && !connectionAttachmentProvider) {
|
|
1260
|
+
if ((hasPendingAttachments || hasPreUploadedAttachments) && !connectionAttachmentProvider) {
|
|
1247
1261
|
setError("Attachments are only supported on OpenAI/OpenAI-compatible and Anthropic connections.");
|
|
1248
1262
|
stopMicAfterValidationFailure();
|
|
1249
1263
|
return;
|
|
@@ -1305,7 +1319,7 @@ export default function ChatView({
|
|
|
1305
1319
|
return;
|
|
1306
1320
|
}
|
|
1307
1321
|
|
|
1308
|
-
let uploadedAttachments: ChatAttachment[] =
|
|
1322
|
+
let uploadedAttachments: ChatAttachment[] = preUploadedAttachments;
|
|
1309
1323
|
if (pendingComposerAttachments.length > 0) {
|
|
1310
1324
|
setIsUploadingAttachments(true);
|
|
1311
1325
|
try {
|
|
@@ -1326,10 +1340,11 @@ export default function ChatView({
|
|
|
1326
1340
|
if (!uploadResponse.ok) {
|
|
1327
1341
|
throw new Error(uploadPayload.error || "Failed to upload attachments.");
|
|
1328
1342
|
}
|
|
1329
|
-
|
|
1330
|
-
if (
|
|
1343
|
+
const newAttachments = normalizeChatAttachments(uploadPayload.attachments);
|
|
1344
|
+
if (newAttachments.length !== pendingComposerAttachments.length) {
|
|
1331
1345
|
throw new Error("One or more attachments were rejected during upload.");
|
|
1332
1346
|
}
|
|
1347
|
+
uploadedAttachments = [...uploadedAttachments, ...newAttachments];
|
|
1333
1348
|
if (
|
|
1334
1349
|
connectionAttachmentProvider &&
|
|
1335
1350
|
uploadedAttachments.some(
|
|
@@ -1354,9 +1369,11 @@ export default function ChatView({
|
|
|
1354
1369
|
(inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0) + 1,
|
|
1355
1370
|
);
|
|
1356
1371
|
setError(null);
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1372
|
+
if (resetComposer) {
|
|
1373
|
+
setInput("");
|
|
1374
|
+
setQuotedContext(null);
|
|
1375
|
+
}
|
|
1376
|
+
const capturedQuotedContext = hasQuoted ? quotedContext : null;
|
|
1360
1377
|
setFocusedMessageId(null);
|
|
1361
1378
|
pendingSendScrollThreadIdRef.current = resolvedThread.id;
|
|
1362
1379
|
isUserNearBottomRef.current = true;
|
|
@@ -1371,16 +1388,19 @@ export default function ChatView({
|
|
|
1371
1388
|
: `Explain this: "${capturedQuotedContext}"`
|
|
1372
1389
|
: trimmed;
|
|
1373
1390
|
|
|
1374
|
-
const isFirstMessage =
|
|
1391
|
+
const isFirstMessage =
|
|
1392
|
+
resolvedThread.title === "New chat" &&
|
|
1393
|
+
(options?.parentMessageId ?? resolvedThread.headMessageId) === null;
|
|
1375
1394
|
|
|
1376
1395
|
const { userMessage, assistantMessage } = await appendUserAndAssistant({
|
|
1377
1396
|
thread: resolvedThread,
|
|
1378
1397
|
content: contentToSend,
|
|
1379
1398
|
attachments: uploadedAttachments,
|
|
1399
|
+
parentId: options?.parentMessageId,
|
|
1380
1400
|
provider: connection.kind === "builtin" ? connection.provider ?? connection.id : connection.id,
|
|
1381
1401
|
model,
|
|
1382
1402
|
});
|
|
1383
|
-
if (pendingComposerAttachments.length > 0) {
|
|
1403
|
+
if (resetComposer && pendingComposerAttachments.length > 0) {
|
|
1384
1404
|
const sentAttachmentIds = new Set(
|
|
1385
1405
|
pendingComposerAttachments.map((attachment) => attachment.id),
|
|
1386
1406
|
);
|
|
@@ -1966,6 +1986,116 @@ export default function ChatView({
|
|
|
1966
1986
|
toolEventsByMessage,
|
|
1967
1987
|
]);
|
|
1968
1988
|
|
|
1989
|
+
const handleResendEditedUserMessage = useCallback(
|
|
1990
|
+
async (messageId: string, nextContent: string) => {
|
|
1991
|
+
const resolvedThread =
|
|
1992
|
+
thread || (activeThreadId ? await db.threads.get(activeThreadId) : null);
|
|
1993
|
+
if (!resolvedThread) {
|
|
1994
|
+
return false;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
const targetMessage = messageMap.get(messageId);
|
|
1998
|
+
if (!targetMessage || targetMessage.role !== "user") {
|
|
1999
|
+
setError("Could not edit that message.");
|
|
2000
|
+
return false;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
const isMessageInActivePath = threadMessages.some((message) => message.id === messageId);
|
|
2004
|
+
if (!isMessageInActivePath) {
|
|
2005
|
+
setError("That message is not in the active thread.");
|
|
2006
|
+
return false;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const trimmedContent = nextContent.trim();
|
|
2010
|
+
const existingAttachments = normalizeChatAttachments(targetMessage.attachments);
|
|
2011
|
+
if (!trimmedContent && existingAttachments.length === 0) {
|
|
2012
|
+
setError("Message cannot be empty.");
|
|
2013
|
+
return false;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
if (!settings) {
|
|
2017
|
+
setError("Settings not loaded yet.");
|
|
2018
|
+
return false;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
if (!connection) {
|
|
2022
|
+
setError("Select a model connection before sending a message.");
|
|
2023
|
+
onOpenSettings();
|
|
2024
|
+
return false;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const connectionPayload = toChatConnectionPayload(connection, settings);
|
|
2028
|
+
const requiresApiKey =
|
|
2029
|
+
connectionPayload.kind === "builtin" &&
|
|
2030
|
+
(connectionPayload.provider === "openai" ||
|
|
2031
|
+
connectionPayload.provider === "anthropic" ||
|
|
2032
|
+
connectionPayload.provider === "google");
|
|
2033
|
+
if (requiresApiKey && !connectionPayload.apiKey) {
|
|
2034
|
+
setError("Add an API key in Settings before sending a message.");
|
|
2035
|
+
onOpenSettings();
|
|
2036
|
+
return false;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
const connectionAttachmentProvider = getAttachmentProviderForConnection(connectionPayload);
|
|
2040
|
+
const currentPathMessages = buildPath(messageId, messageMap);
|
|
2041
|
+
const existingAttachmentProviders = new Set<ChatAttachment["provider"]>();
|
|
2042
|
+
for (const message of currentPathMessages) {
|
|
2043
|
+
const attachments = normalizeChatAttachments(message.attachments);
|
|
2044
|
+
for (const attachment of attachments) {
|
|
2045
|
+
existingAttachmentProviders.add(attachment.provider);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
if (existingAttachmentProviders.size > 1) {
|
|
2049
|
+
setError("This thread has mixed attachment providers. Start a new chat to continue.");
|
|
2050
|
+
return false;
|
|
2051
|
+
}
|
|
2052
|
+
const existingProvider = [...existingAttachmentProviders][0] ?? null;
|
|
2053
|
+
if (existingProvider && connectionAttachmentProvider !== existingProvider) {
|
|
2054
|
+
setError(
|
|
2055
|
+
`This thread has ${existingProvider} attachments. Switch back to that provider or start a new chat.`,
|
|
2056
|
+
);
|
|
2057
|
+
return false;
|
|
2058
|
+
}
|
|
2059
|
+
if (existingAttachments.length > 0 && !connectionAttachmentProvider) {
|
|
2060
|
+
setError("Attachments are only supported on OpenAI/OpenAI-compatible and Anthropic connections.");
|
|
2061
|
+
return false;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
if (!model) {
|
|
2065
|
+
setError("Select a model before sending a message.");
|
|
2066
|
+
return false;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
const threadLocked =
|
|
2070
|
+
Boolean(streamingByThreadRef.current[resolvedThread.id]) ||
|
|
2071
|
+
(inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0) > 0;
|
|
2072
|
+
if (threadLocked || isUploadingAttachments) {
|
|
2073
|
+
setError("This thread is already responding.");
|
|
2074
|
+
return false;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
void handleSend(trimmedContent, {
|
|
2078
|
+
parentMessageId: targetMessage.parentId,
|
|
2079
|
+
historyHeadMessageId: messageId,
|
|
2080
|
+
preUploadedAttachments: existingAttachments,
|
|
2081
|
+
suppressComposerReset: true,
|
|
2082
|
+
});
|
|
2083
|
+
return true;
|
|
2084
|
+
},
|
|
2085
|
+
[
|
|
2086
|
+
thread,
|
|
2087
|
+
activeThreadId,
|
|
2088
|
+
settings,
|
|
2089
|
+
connection,
|
|
2090
|
+
model,
|
|
2091
|
+
onOpenSettings,
|
|
2092
|
+
messageMap,
|
|
2093
|
+
threadMessages,
|
|
2094
|
+
isUploadingAttachments,
|
|
2095
|
+
handleSend,
|
|
2096
|
+
],
|
|
2097
|
+
);
|
|
2098
|
+
|
|
1969
2099
|
useEffect(() => {
|
|
1970
2100
|
handleSendRef.current = handleSend;
|
|
1971
2101
|
}, [handleSend]);
|
|
@@ -2054,6 +2184,7 @@ export default function ChatView({
|
|
|
2054
2184
|
approvals={messageApprovals}
|
|
2055
2185
|
onResolveApproval={handleApprovalDecision}
|
|
2056
2186
|
approvalBusyIds={approvalBusyIds}
|
|
2187
|
+
onResendEditedUserMessage={handleResendEditedUserMessage}
|
|
2057
2188
|
/>
|
|
2058
2189
|
</div>
|
|
2059
2190
|
);
|
|
@@ -2072,6 +2203,7 @@ export default function ChatView({
|
|
|
2072
2203
|
toolApprovalsByMessage,
|
|
2073
2204
|
handleApprovalDecision,
|
|
2074
2205
|
approvalBusyIds,
|
|
2206
|
+
handleResendEditedUserMessage,
|
|
2075
2207
|
streamDraftByMessageId,
|
|
2076
2208
|
]
|
|
2077
2209
|
);
|
|
@@ -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,
|
|
@@ -118,6 +118,10 @@ function normalizeMarkdownStructure(content: string) {
|
|
|
118
118
|
return normalized.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
function normalizeUserTextForEditor(content: string): string {
|
|
122
|
+
return content.replace(/\s+/g, " ").trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
121
125
|
function stabilizeStreamingMarkdown(content: string) {
|
|
122
126
|
let stable = content;
|
|
123
127
|
const fenceMatches = stable.match(/```/g);
|
|
@@ -1242,6 +1246,7 @@ function MessageCard({
|
|
|
1242
1246
|
approvals,
|
|
1243
1247
|
onResolveApproval,
|
|
1244
1248
|
approvalBusyIds,
|
|
1249
|
+
onResendEditedUserMessage,
|
|
1245
1250
|
}: {
|
|
1246
1251
|
message: MessageNode;
|
|
1247
1252
|
onAddThread: (message: MessageNode) => void;
|
|
@@ -1259,12 +1264,20 @@ function MessageCard({
|
|
|
1259
1264
|
argsOverride?: Record<string, unknown>,
|
|
1260
1265
|
) => void;
|
|
1261
1266
|
approvalBusyIds?: Record<string, boolean>;
|
|
1267
|
+
onResendEditedUserMessage?: (messageId: string, content: string) => Promise<boolean>;
|
|
1262
1268
|
}) {
|
|
1263
1269
|
const [copied, setCopied] = useState(false);
|
|
1264
1270
|
const [threadAdded, setThreadAdded] = useState(false);
|
|
1265
1271
|
const [assistantCollapsed, setAssistantCollapsed] = useState(false);
|
|
1266
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);
|
|
1267
1279
|
const isAssistant = message.role === "assistant";
|
|
1280
|
+
const canEditUserMessage = !isAssistant && Boolean(onResendEditedUserMessage);
|
|
1268
1281
|
const canAddThread = threads.length < 4;
|
|
1269
1282
|
const shelfThreads = [
|
|
1270
1283
|
...(baseThreadId ? [{ id: baseThreadId, title: "Thread 1", isBase: true }] : []),
|
|
@@ -1284,6 +1297,8 @@ function MessageCard({
|
|
|
1284
1297
|
() => (message.role === "user" ? normalizeChatAttachments(message.attachments) : []),
|
|
1285
1298
|
[message.role, message.attachments],
|
|
1286
1299
|
);
|
|
1300
|
+
const canSubmitUserEdit =
|
|
1301
|
+
editedUserContent.trim().length > 0 || userAttachments.length > 0;
|
|
1287
1302
|
const sourcesOrderedByCitation = useMemo(
|
|
1288
1303
|
() => reorderSourcesByCitationOrder(messageTextContent, messageSources),
|
|
1289
1304
|
[messageTextContent, messageSources],
|
|
@@ -1342,9 +1357,65 @@ function MessageCard({
|
|
|
1342
1357
|
return "Working on your request.";
|
|
1343
1358
|
}, [messageTextContent, timelineItems, approvalItems]);
|
|
1344
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
|
+
|
|
1345
1408
|
return (
|
|
1346
1409
|
<div className="message-stack group">
|
|
1347
|
-
<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
|
+
>
|
|
1348
1419
|
{isAssistant ? (
|
|
1349
1420
|
<div className="assistant-collapse-row">
|
|
1350
1421
|
<button
|
|
@@ -1441,7 +1512,52 @@ function MessageCard({
|
|
|
1441
1512
|
))}
|
|
1442
1513
|
</div>
|
|
1443
1514
|
) : null}
|
|
1444
|
-
{
|
|
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}
|
|
1445
1561
|
</div>
|
|
1446
1562
|
)}
|
|
1447
1563
|
|
|
@@ -1594,61 +1710,73 @@ function MessageCard({
|
|
|
1594
1710
|
</div>
|
|
1595
1711
|
) : null}
|
|
1596
1712
|
</div>
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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}
|
|
1601
1741
|
<button
|
|
1602
|
-
className={`flex items-center
|
|
1603
|
-
? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
|
|
1604
|
-
: "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"
|
|
1605
1743
|
}`}
|
|
1606
1744
|
onClick={async () => {
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
window.setTimeout(() => setThreadAdded(false), 1200);
|
|
1745
|
+
await navigator.clipboard.writeText(messageTextContent);
|
|
1746
|
+
setCopied(true);
|
|
1747
|
+
window.setTimeout(() => setCopied(false), 1200);
|
|
1611
1748
|
}}
|
|
1612
|
-
disabled={!canAddThread}
|
|
1613
1749
|
>
|
|
1614
|
-
{
|
|
1750
|
+
{copied ? (
|
|
1615
1751
|
<Check className="h-4 w-4 text-[var(--accent)]" />
|
|
1616
1752
|
) : (
|
|
1617
|
-
|
|
1618
|
-
<PlusCircle className="h-4 w-4" />
|
|
1619
|
-
Thread
|
|
1620
|
-
</>
|
|
1753
|
+
<Copy className="h-4 w-4" />
|
|
1621
1754
|
)}
|
|
1622
1755
|
</button>
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
{
|
|
1634
|
-
<
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
>
|
|
1648
|
-
<Pencil className="h-4 w-4" />
|
|
1649
|
-
</button>
|
|
1650
|
-
) : null}
|
|
1651
|
-
</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}
|
|
1652
1780
|
</div>
|
|
1653
1781
|
);
|
|
1654
1782
|
}
|
package/template/src/lib/data.ts
CHANGED
|
@@ -294,10 +294,11 @@ export async function appendUserAndAssistant(params: {
|
|
|
294
294
|
thread: Thread;
|
|
295
295
|
content: string;
|
|
296
296
|
attachments?: ChatAttachment[];
|
|
297
|
+
parentId?: string | null;
|
|
297
298
|
provider: string;
|
|
298
299
|
model: string;
|
|
299
300
|
}) {
|
|
300
|
-
const { thread, content, attachments, provider, model } = params;
|
|
301
|
+
const { thread, content, attachments, parentId = thread.headMessageId, provider, model } = params;
|
|
301
302
|
const now = Date.now();
|
|
302
303
|
const userId = nanoid();
|
|
303
304
|
const assistantId = nanoid();
|
|
@@ -305,7 +306,7 @@ export async function appendUserAndAssistant(params: {
|
|
|
305
306
|
const userMessage: MessageNode = {
|
|
306
307
|
id: userId,
|
|
307
308
|
conversationId: thread.conversationId,
|
|
308
|
-
parentId
|
|
309
|
+
parentId,
|
|
309
310
|
role: "user",
|
|
310
311
|
content,
|
|
311
312
|
...(attachments?.length ? { attachments } : {}),
|