@denarolabs/email-template 1.0.5 → 1.0.6
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/dist/index.d.ts +425 -8
- package/dist/index.js +9841 -7
- package/package.json +16 -23
- package/dist/components/ai-email-editor/AIEmailEditor.d.ts +0 -3
- package/dist/components/ai-email-editor/AIEmailEditor.js +0 -207
- package/dist/components/ai-email-editor/chat-message.d.ts +0 -36
- package/dist/components/ai-email-editor/chat-message.js +0 -49
- package/dist/components/ai-email-editor/images.d.ts +0 -2
- package/dist/components/ai-email-editor/images.js +0 -14
- package/dist/components/ai-email-editor/model-picker.d.ts +0 -7
- package/dist/components/ai-email-editor/model-picker.js +0 -9
- package/dist/components/ai-email-editor/preview.d.ts +0 -35
- package/dist/components/ai-email-editor/preview.js +0 -728
- package/dist/components/ai-email-editor/send-test-email-modal.d.ts +0 -13
- package/dist/components/ai-email-editor/send-test-email-modal.js +0 -70
- package/dist/components/ai-email-editor/stream.d.ts +0 -20
- package/dist/components/ai-email-editor/stream.js +0 -57
- package/dist/components/ai-email-editor/use-ai-email-editor.d.ts +0 -117
- package/dist/components/ai-email-editor/use-ai-email-editor.js +0 -1308
- package/dist/components/ai-email-editor/view-html-modal.d.ts +0 -9
- package/dist/components/ai-email-editor/view-html-modal.js +0 -37
- package/dist/lib/ai-stream-contract.d.ts +0 -99
- package/dist/lib/ai-stream-contract.js +0 -35
- package/dist/lib/build-default-system-prompt.d.ts +0 -2
- package/dist/lib/build-default-system-prompt.js +0 -37
- package/dist/lib/capture-email-preview.d.ts +0 -5
- package/dist/lib/capture-email-preview.js +0 -73
- package/dist/lib/cn.d.ts +0 -2
- package/dist/lib/cn.js +0 -5
- package/dist/lib/merge-tag-validation.d.ts +0 -3
- package/dist/lib/merge-tag-validation.js +0 -45
- package/dist/lib/rasterize-image-client.d.ts +0 -4
- package/dist/lib/rasterize-image-client.js +0 -47
- package/dist/lib/strip-html-code-fences.d.ts +0 -1
- package/dist/lib/strip-html-code-fences.js +0 -6
- package/dist/schemas/aiEmail.d.ts +0 -224
- package/dist/schemas/aiEmail.js +0 -29
- package/dist/schemas/aiEmailResponse.d.ts +0 -15
- package/dist/schemas/aiEmailResponse.js +0 -15
- package/dist/types.d.ts +0 -57
- package/dist/types.js +0 -1
- package/dist/ui/button.d.ts +0 -11
- package/dist/ui/button.js +0 -34
- package/dist/ui/dialog.d.ts +0 -33
- package/dist/ui/dialog.js +0 -39
- package/dist/ui/dropdown-menu.d.ts +0 -8
- package/dist/ui/dropdown-menu.js +0 -23
- package/dist/ui/input.d.ts +0 -2
- package/dist/ui/input.js +0 -6
- package/dist/ui/textarea.d.ts +0 -2
- package/dist/ui/textarea.js +0 -7
- package/src/styles.css +0 -197
|
@@ -1,1308 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { buildDefaultSystemPrompt } from "../../lib/build-default-system-prompt";
|
|
3
|
-
import { captureEmailPreviewScreenshot } from "../../lib/capture-email-preview";
|
|
4
|
-
import { getMergeTagError } from "../../lib/merge-tag-validation";
|
|
5
|
-
import { prepareImageFileForUpload } from "../../lib/rasterize-image-client";
|
|
6
|
-
import { stripHtmlCodeFences } from "../../lib/strip-html-code-fences";
|
|
7
|
-
import { annotateHtmlForEditing, attachImageResize, finishInlineEdits, focusEditableElementAtPoint, getBlockHtmlById, insertImageBlock, insertImageBlockAtPoint, prepareImagesForEditing, serializeHtmlDocument, stripEditorChrome, syncFlaggedElements, sanitizeEmailHtml, } from "./preview";
|
|
8
|
-
import { collectAllReferencedImageUrls } from "./images";
|
|
9
|
-
import { detectPhase, defaultSuggestions, extractStreamParts, parseStructuredStream, phaseLabel, resolveInitialSuggestions, } from "./stream";
|
|
10
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
11
|
-
const CHAT_SCROLL_BOTTOM_THRESHOLD = 80;
|
|
12
|
-
const MAX_CONTEXT_IMAGES = 12;
|
|
13
|
-
const EMPTY_PREVIEW_HTML = '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin:0;padding:0;background:#ffffff;"></body></html>';
|
|
14
|
-
function isChatNearBottom(el) {
|
|
15
|
-
return el.scrollHeight - el.scrollTop - el.clientHeight <= CHAT_SCROLL_BOTTOM_THRESHOLD;
|
|
16
|
-
}
|
|
17
|
-
const CHAT_PIN_TOP_PADDING = 16;
|
|
18
|
-
function computeChatPinSpacerHeight(container) {
|
|
19
|
-
if (!container)
|
|
20
|
-
return 0;
|
|
21
|
-
return Math.max(0, container.clientHeight - CHAT_PIN_TOP_PADDING);
|
|
22
|
-
}
|
|
23
|
-
function scrollChatMessageToTop(container, messageId) {
|
|
24
|
-
const messageEl = container.querySelector(`[data-message-id="${messageId}"]`);
|
|
25
|
-
if (!messageEl)
|
|
26
|
-
return null;
|
|
27
|
-
const containerRect = container.getBoundingClientRect();
|
|
28
|
-
const messageRect = messageEl.getBoundingClientRect();
|
|
29
|
-
const messageTopInScroll = container.scrollTop + (messageRect.top - containerRect.top);
|
|
30
|
-
const targetScroll = messageTopInScroll - CHAT_PIN_TOP_PADDING;
|
|
31
|
-
return Math.max(0, Math.min(targetScroll, container.scrollHeight - container.clientHeight));
|
|
32
|
-
}
|
|
33
|
-
function suggestLabelFromFile(file) {
|
|
34
|
-
const base = (file.name || "Image")
|
|
35
|
-
.replace(/\.[^.]+$/, "")
|
|
36
|
-
.replace(/[-_]+/g, " ")
|
|
37
|
-
.trim();
|
|
38
|
-
return (base || "Image").slice(0, 120);
|
|
39
|
-
}
|
|
40
|
-
function createId() {
|
|
41
|
-
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
42
|
-
}
|
|
43
|
-
function snapshot(html, label) {
|
|
44
|
-
return { id: createId(), html, label, timestamp: Date.now() };
|
|
45
|
-
}
|
|
46
|
-
function toPersisted(messages) {
|
|
47
|
-
return messages.map(({ pending: _p, error: _e, ...msg }) => msg);
|
|
48
|
-
}
|
|
49
|
-
function cloneLoadedSession(session) {
|
|
50
|
-
return JSON.parse(JSON.stringify(session));
|
|
51
|
-
}
|
|
52
|
-
export function useAiEmailEditor({ name, savedTemplate, onSave, streamRoute, mergeTags, mode = "email", systemPrompt, onUploadImage, onDeleteImage, onError, onSuccess, open, models, defaultModel, initialSuggestions, }) {
|
|
53
|
-
const resolvedInitialSuggestions = useMemo(() => resolveInitialSuggestions(initialSuggestions), [initialSuggestions]);
|
|
54
|
-
const getSuggestions = useCallback((hasHtml) => hasHtml ? defaultSuggestions(true) : [...resolvedInitialSuggestions], [resolvedInitialSuggestions]);
|
|
55
|
-
const modelOptions = useMemo(() => {
|
|
56
|
-
if (models?.length)
|
|
57
|
-
return models;
|
|
58
|
-
if (defaultModel)
|
|
59
|
-
return [{ id: defaultModel, label: defaultModel }];
|
|
60
|
-
return [];
|
|
61
|
-
}, [models, defaultModel]);
|
|
62
|
-
const resolvedDefaultModel = useMemo(() => {
|
|
63
|
-
if (defaultModel && modelOptions.some((m) => m.id === defaultModel))
|
|
64
|
-
return defaultModel;
|
|
65
|
-
return modelOptions[0]?.id ?? "";
|
|
66
|
-
}, [models, defaultModel, modelOptions]);
|
|
67
|
-
const resolvedSystemPrompt = useMemo(() => systemPrompt ?? buildDefaultSystemPrompt(mergeTags, mode), [systemPrompt, mergeTags, mode]);
|
|
68
|
-
const [selectedModel, setSelectedModel] = useState(resolvedDefaultModel);
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
setSelectedModel((current) => modelOptions.some((m) => m.id === current) ? current : resolvedDefaultModel);
|
|
71
|
-
}, [modelOptions, resolvedDefaultModel]);
|
|
72
|
-
const iframeRef = useRef(null);
|
|
73
|
-
const scrollRef = useRef(null);
|
|
74
|
-
const shouldAutoScrollRef = useRef(true);
|
|
75
|
-
const suppressScrollTrackingRef = useRef(false);
|
|
76
|
-
const pendingScrollPinRef = useRef(null);
|
|
77
|
-
const [chatPinSpacerHeight, setChatPinSpacerHeight] = useState(0);
|
|
78
|
-
const programChatScroll = useCallback((top) => {
|
|
79
|
-
const el = scrollRef.current;
|
|
80
|
-
if (!el)
|
|
81
|
-
return;
|
|
82
|
-
suppressScrollTrackingRef.current = true;
|
|
83
|
-
el.scrollTop = top;
|
|
84
|
-
requestAnimationFrame(() => {
|
|
85
|
-
suppressScrollTrackingRef.current = false;
|
|
86
|
-
});
|
|
87
|
-
}, []);
|
|
88
|
-
const htmlRef = useRef("");
|
|
89
|
-
const selectModeRef = useRef(false);
|
|
90
|
-
const selectedBlockIdsRef = useRef([]);
|
|
91
|
-
const onBlockToggleRef = useRef(() => { });
|
|
92
|
-
const abortRef = useRef(null);
|
|
93
|
-
const dropHandlerRef = useRef(() => { });
|
|
94
|
-
const pasteHandlerRef = useRef(() => { });
|
|
95
|
-
const inFlightRef = useRef(null);
|
|
96
|
-
const sessionContextImagesRef = useRef([]);
|
|
97
|
-
const loadedSessionRef = useRef(null);
|
|
98
|
-
const initialImageUrlsRef = useRef(new Set());
|
|
99
|
-
const pushPreviewUndoRef = useRef(() => { });
|
|
100
|
-
const previewUndoStackRef = useRef([]);
|
|
101
|
-
const [previewUndoCount, setPreviewUndoCount] = useState(0);
|
|
102
|
-
const [html, setHtml] = useState("");
|
|
103
|
-
const [loading, setLoading] = useState(false);
|
|
104
|
-
const [saving, setSaving] = useState(false);
|
|
105
|
-
const [dirty, setDirty] = useState(false);
|
|
106
|
-
const [messages, setMessages] = useState([]);
|
|
107
|
-
const [snapshots, setSnapshots] = useState([]);
|
|
108
|
-
const [snapshotIndex, setSnapshotIndex] = useState(0);
|
|
109
|
-
const [input, setInput] = useState("");
|
|
110
|
-
const [contextImages, setContextImages] = useState([]);
|
|
111
|
-
const [sessionContextImages, setSessionContextImages] = useState([]);
|
|
112
|
-
const [contextImageBusy, setContextImageBusy] = useState(false);
|
|
113
|
-
const [chatDropActive, setChatDropActive] = useState(false);
|
|
114
|
-
const [isStreaming, setIsStreaming] = useState(false);
|
|
115
|
-
const [editingMessageId, setEditingMessageId] = useState(null);
|
|
116
|
-
const [editDraft, setEditDraft] = useState("");
|
|
117
|
-
const [editContextImages, setEditContextImages] = useState([]);
|
|
118
|
-
const [selectMode, setSelectMode] = useState(false);
|
|
119
|
-
const [selectedBlockIds, setSelectedBlockIds] = useState([]);
|
|
120
|
-
const [resubmitConfirmOpen, setResubmitConfirmOpen] = useState(false);
|
|
121
|
-
const [revertConfirmOpen, setRevertConfirmOpen] = useState(false);
|
|
122
|
-
const [revertConfirmMessageId, setRevertConfirmMessageId] = useState(null);
|
|
123
|
-
const [backConfirmOpen, setBackConfirmOpen] = useState(false);
|
|
124
|
-
const [suggestions, setSuggestions] = useState(() => [...resolvedInitialSuggestions]);
|
|
125
|
-
const [previewDropActive, setPreviewDropActive] = useState(false);
|
|
126
|
-
const [systemPromptOverride, setSystemPromptOverride] = useState(null);
|
|
127
|
-
const effectiveSystemPrompt = systemPromptOverride ?? resolvedSystemPrompt;
|
|
128
|
-
htmlRef.current = html;
|
|
129
|
-
selectModeRef.current = selectMode;
|
|
130
|
-
selectedBlockIdsRef.current = selectedBlockIds;
|
|
131
|
-
sessionContextImagesRef.current = sessionContextImages;
|
|
132
|
-
const reportError = useCallback((error, source) => {
|
|
133
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
134
|
-
onError(err, { source });
|
|
135
|
-
}, [onError]);
|
|
136
|
-
const reportSuccess = useCallback((source, message) => {
|
|
137
|
-
onSuccess?.({ source, message });
|
|
138
|
-
}, [onSuccess]);
|
|
139
|
-
const syncOrphanedImages = useCallback(async (priorMessages, priorSessionImages, priorPending, nextMessages, nextSessionImages, nextPending) => {
|
|
140
|
-
const previous = collectAllReferencedImageUrls(priorMessages, priorSessionImages, priorPending);
|
|
141
|
-
const next = collectAllReferencedImageUrls(nextMessages, nextSessionImages, nextPending);
|
|
142
|
-
for (const url of previous) {
|
|
143
|
-
if (next.has(url) || initialImageUrlsRef.current.has(url))
|
|
144
|
-
continue;
|
|
145
|
-
try {
|
|
146
|
-
await onDeleteImage(url);
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
console.warn("Failed to delete orphaned image", url, error);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}, [onDeleteImage]);
|
|
153
|
-
const syncFromIframe = useCallback((updateState = false) => {
|
|
154
|
-
const doc = iframeRef.current?.contentDocument;
|
|
155
|
-
if (!doc)
|
|
156
|
-
return;
|
|
157
|
-
const next = serializeHtmlDocument(doc);
|
|
158
|
-
if (next === htmlRef.current)
|
|
159
|
-
return;
|
|
160
|
-
htmlRef.current = next;
|
|
161
|
-
setDirty(true);
|
|
162
|
-
if (updateState)
|
|
163
|
-
setHtml(next);
|
|
164
|
-
}, []);
|
|
165
|
-
const getCleanHtml = useCallback(() => {
|
|
166
|
-
syncFromIframe(false);
|
|
167
|
-
return stripEditorChrome(htmlRef.current);
|
|
168
|
-
}, [syncFromIframe]);
|
|
169
|
-
const applyHtml = useCallback((next, markDirty = true) => {
|
|
170
|
-
setHtml(next);
|
|
171
|
-
htmlRef.current = next;
|
|
172
|
-
if (markDirty)
|
|
173
|
-
setDirty(true);
|
|
174
|
-
}, []);
|
|
175
|
-
const clearPreviewUndo = useCallback(() => {
|
|
176
|
-
previewUndoStackRef.current = [];
|
|
177
|
-
setPreviewUndoCount(0);
|
|
178
|
-
}, []);
|
|
179
|
-
pushPreviewUndoRef.current = () => {
|
|
180
|
-
syncFromIframe(false);
|
|
181
|
-
const current = htmlRef.current;
|
|
182
|
-
const stack = previewUndoStackRef.current;
|
|
183
|
-
if (stack.length > 0 && stack[stack.length - 1] === current)
|
|
184
|
-
return;
|
|
185
|
-
stack.push(current);
|
|
186
|
-
if (stack.length > 50)
|
|
187
|
-
stack.shift();
|
|
188
|
-
setPreviewUndoCount(stack.length);
|
|
189
|
-
};
|
|
190
|
-
const undoPreviewEdit = useCallback(() => {
|
|
191
|
-
const stack = previewUndoStackRef.current;
|
|
192
|
-
const previous = stack.pop();
|
|
193
|
-
if (!previous)
|
|
194
|
-
return false;
|
|
195
|
-
setPreviewUndoCount(stack.length);
|
|
196
|
-
applyHtml(previous, true);
|
|
197
|
-
return true;
|
|
198
|
-
}, [applyHtml]);
|
|
199
|
-
const toggleBlockSelection = useCallback((blockId) => {
|
|
200
|
-
setSelectedBlockIds((prev) => prev.includes(blockId) ? prev.filter((id) => id !== blockId) : [...prev, blockId]);
|
|
201
|
-
}, []);
|
|
202
|
-
onBlockToggleRef.current = toggleBlockSelection;
|
|
203
|
-
const exitSelectMode = useCallback(() => {
|
|
204
|
-
setSelectMode(false);
|
|
205
|
-
setSelectedBlockIds([]);
|
|
206
|
-
}, []);
|
|
207
|
-
const toggleSelectMode = useCallback(() => {
|
|
208
|
-
setSelectMode((prev) => {
|
|
209
|
-
if (prev)
|
|
210
|
-
setSelectedBlockIds([]);
|
|
211
|
-
return !prev;
|
|
212
|
-
});
|
|
213
|
-
}, []);
|
|
214
|
-
const getSelectedBlocks = useCallback(() => {
|
|
215
|
-
const doc = iframeRef.current?.contentDocument;
|
|
216
|
-
if (!doc)
|
|
217
|
-
return [];
|
|
218
|
-
return selectedBlockIds
|
|
219
|
-
.map((id) => {
|
|
220
|
-
const blockHtml = getBlockHtmlById(doc, id);
|
|
221
|
-
return blockHtml ? { id, html: blockHtml } : null;
|
|
222
|
-
})
|
|
223
|
-
.filter((block) => !!block);
|
|
224
|
-
}, [selectedBlockIds]);
|
|
225
|
-
const insertImageIntoTemplate = useCallback((url, label, clientX, clientY) => {
|
|
226
|
-
const doc = iframeRef.current?.contentDocument;
|
|
227
|
-
if (!doc || !htmlRef.current) {
|
|
228
|
-
applyHtml(`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin:0;padding:24px;font-family:Arial,sans-serif;"><div style="text-align:center;margin:16px 0;"><img src="${url}" alt="${label}" style="max-width:100%;height:auto;display:inline-block;border:0;"></div></body></html>`);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
if (clientX !== undefined && clientY !== undefined) {
|
|
232
|
-
insertImageBlockAtPoint(doc, url, label, clientX, clientY);
|
|
233
|
-
}
|
|
234
|
-
else {
|
|
235
|
-
const targetId = selectedBlockIds[0];
|
|
236
|
-
const targetBlock = targetId
|
|
237
|
-
? doc.querySelector(`[data-edit-id="${targetId}"]`)
|
|
238
|
-
: null;
|
|
239
|
-
if (targetBlock?.parentElement) {
|
|
240
|
-
const rect = targetBlock.getBoundingClientRect();
|
|
241
|
-
insertImageBlockAtPoint(doc, url, label, rect.left + rect.width / 2, rect.bottom - 1);
|
|
242
|
-
}
|
|
243
|
-
else {
|
|
244
|
-
insertImageBlock(doc, url, label);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
syncFromIframe(true);
|
|
248
|
-
}, [applyHtml, syncFromIframe, selectedBlockIds]);
|
|
249
|
-
const uploadAndInsertImage = useCallback(async (file, placement) => {
|
|
250
|
-
if (!file.type.startsWith("image/")) {
|
|
251
|
-
reportError(new Error("Only image files can be uploaded."), "upload");
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
setContextImageBusy(true);
|
|
255
|
-
try {
|
|
256
|
-
const prepared = await prepareImageFileForUpload(file);
|
|
257
|
-
const { url } = await onUploadImage(prepared);
|
|
258
|
-
const label = suggestLabelFromFile(prepared);
|
|
259
|
-
if (placement === "end") {
|
|
260
|
-
insertImageIntoTemplate(url, label);
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
insertImageIntoTemplate(url, label, placement.x, placement.y);
|
|
264
|
-
}
|
|
265
|
-
reportSuccess("upload", "Image inserted");
|
|
266
|
-
}
|
|
267
|
-
catch (error) {
|
|
268
|
-
reportError(error, "upload");
|
|
269
|
-
}
|
|
270
|
-
finally {
|
|
271
|
-
setContextImageBusy(false);
|
|
272
|
-
}
|
|
273
|
-
}, [onUploadImage, insertImageIntoTemplate, reportError, reportSuccess]);
|
|
274
|
-
const mountIframe = useCallback((sourceHtml) => {
|
|
275
|
-
const doc = iframeRef.current?.contentDocument;
|
|
276
|
-
if (!doc)
|
|
277
|
-
return;
|
|
278
|
-
doc.open();
|
|
279
|
-
doc.write(annotateHtmlForEditing(sourceHtml.trim() ? sourceHtml : EMPTY_PREVIEW_HTML));
|
|
280
|
-
doc.close();
|
|
281
|
-
prepareImagesForEditing(doc);
|
|
282
|
-
const detachImageResize = attachImageResize(doc, {
|
|
283
|
-
isSelectMode: () => selectModeRef.current,
|
|
284
|
-
onBeforeEdit: () => pushPreviewUndoRef.current(),
|
|
285
|
-
onResizeEnd: () => syncFromIframe(),
|
|
286
|
-
});
|
|
287
|
-
const handleMouseDown = (event) => {
|
|
288
|
-
const target = event.target?.closest("[data-edit-id]");
|
|
289
|
-
if (selectModeRef.current || !target || target.hasAttribute("contenteditable")) {
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
event.preventDefault();
|
|
293
|
-
event.stopPropagation();
|
|
294
|
-
finishInlineEdits(doc, target);
|
|
295
|
-
focusEditableElementAtPoint(target, event.clientX, event.clientY);
|
|
296
|
-
};
|
|
297
|
-
const handleClick = (event) => {
|
|
298
|
-
const target = event.target?.closest("[data-edit-id]");
|
|
299
|
-
if (selectModeRef.current) {
|
|
300
|
-
if (!target) {
|
|
301
|
-
finishInlineEdits(doc);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (target.hasAttribute("contenteditable"))
|
|
305
|
-
return;
|
|
306
|
-
event.preventDefault();
|
|
307
|
-
event.stopPropagation();
|
|
308
|
-
const id = target.getAttribute("data-edit-id");
|
|
309
|
-
if (!id)
|
|
310
|
-
return;
|
|
311
|
-
if (selectedBlockIdsRef.current.includes(id)) {
|
|
312
|
-
finishInlineEdits(doc, target);
|
|
313
|
-
focusEditableElementAtPoint(target, event.clientX, event.clientY);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
finishInlineEdits(doc);
|
|
317
|
-
onBlockToggleRef.current(id);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
if (!target) {
|
|
321
|
-
finishInlineEdits(doc);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if (target.hasAttribute("contenteditable"))
|
|
325
|
-
return;
|
|
326
|
-
};
|
|
327
|
-
const handleBlur = (event) => {
|
|
328
|
-
const target = event.target;
|
|
329
|
-
if (!target?.hasAttribute("contenteditable"))
|
|
330
|
-
return;
|
|
331
|
-
target.removeAttribute("contenteditable");
|
|
332
|
-
syncFromIframe();
|
|
333
|
-
};
|
|
334
|
-
const handleInput = (event) => {
|
|
335
|
-
if (event.target?.hasAttribute("contenteditable")) {
|
|
336
|
-
syncFromIframe();
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
const hasDraggableImage = (dt) => !!dt && dt.types.includes("Files");
|
|
340
|
-
const handleDragOver = (event) => {
|
|
341
|
-
if (!hasDraggableImage(event.dataTransfer))
|
|
342
|
-
return;
|
|
343
|
-
event.preventDefault();
|
|
344
|
-
if (event.dataTransfer)
|
|
345
|
-
event.dataTransfer.dropEffect = "copy";
|
|
346
|
-
setPreviewDropActive(true);
|
|
347
|
-
};
|
|
348
|
-
const handleDragLeave = (event) => {
|
|
349
|
-
if (event.relatedTarget)
|
|
350
|
-
return;
|
|
351
|
-
setPreviewDropActive(false);
|
|
352
|
-
};
|
|
353
|
-
const handleDrop = (event) => {
|
|
354
|
-
if (!hasDraggableImage(event.dataTransfer))
|
|
355
|
-
return;
|
|
356
|
-
event.preventDefault();
|
|
357
|
-
setPreviewDropActive(false);
|
|
358
|
-
dropHandlerRef.current(event);
|
|
359
|
-
};
|
|
360
|
-
const handlePaste = (event) => {
|
|
361
|
-
pasteHandlerRef.current(event);
|
|
362
|
-
};
|
|
363
|
-
doc.body.addEventListener("mousedown", handleMouseDown);
|
|
364
|
-
doc.body.addEventListener("click", handleClick);
|
|
365
|
-
doc.body.addEventListener("blur", handleBlur, true);
|
|
366
|
-
doc.body.addEventListener("input", handleInput, true);
|
|
367
|
-
doc.addEventListener("dragover", handleDragOver);
|
|
368
|
-
doc.addEventListener("dragleave", handleDragLeave);
|
|
369
|
-
doc.addEventListener("drop", handleDrop);
|
|
370
|
-
doc.addEventListener("paste", handlePaste);
|
|
371
|
-
return () => {
|
|
372
|
-
detachImageResize();
|
|
373
|
-
doc.body.removeEventListener("mousedown", handleMouseDown);
|
|
374
|
-
doc.body.removeEventListener("click", handleClick);
|
|
375
|
-
doc.body.removeEventListener("blur", handleBlur, true);
|
|
376
|
-
doc.body.removeEventListener("input", handleInput, true);
|
|
377
|
-
doc.removeEventListener("dragover", handleDragOver);
|
|
378
|
-
doc.removeEventListener("dragleave", handleDragLeave);
|
|
379
|
-
doc.removeEventListener("drop", handleDrop);
|
|
380
|
-
doc.removeEventListener("paste", handlePaste);
|
|
381
|
-
};
|
|
382
|
-
}, [syncFromIframe]);
|
|
383
|
-
const rememberLoadedSession = useCallback((session) => {
|
|
384
|
-
loadedSessionRef.current = cloneLoadedSession(session);
|
|
385
|
-
initialImageUrlsRef.current = collectAllReferencedImageUrls(session.messages, session.contextImages, []);
|
|
386
|
-
}, []);
|
|
387
|
-
const loadFromValue = useCallback(async () => {
|
|
388
|
-
clearPreviewUndo();
|
|
389
|
-
setLoading(true);
|
|
390
|
-
if (!savedTemplate) {
|
|
391
|
-
applyHtml("", false);
|
|
392
|
-
setSuggestions(getSuggestions(false));
|
|
393
|
-
setMessages([]);
|
|
394
|
-
const blankSnapshots = [snapshot("", "Blank")];
|
|
395
|
-
setSnapshots(blankSnapshots);
|
|
396
|
-
setSnapshotIndex(0);
|
|
397
|
-
setSessionContextImages([]);
|
|
398
|
-
sessionContextImagesRef.current = [];
|
|
399
|
-
rememberLoadedSession({
|
|
400
|
-
html: "",
|
|
401
|
-
messages: [],
|
|
402
|
-
snapshots: blankSnapshots,
|
|
403
|
-
snapshotIndex: 0,
|
|
404
|
-
contextImages: [],
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
else {
|
|
408
|
-
const aiSession = savedTemplate.aiSession;
|
|
409
|
-
applyHtml(sanitizeEmailHtml(savedTemplate.html), false);
|
|
410
|
-
setSuggestions(getSuggestions(!!savedTemplate.html));
|
|
411
|
-
const loadedMessages = aiSession?.messages.length ? aiSession.messages : [];
|
|
412
|
-
const loadedSnapshots = aiSession?.messages.length
|
|
413
|
-
? aiSession.snapshots
|
|
414
|
-
: [snapshot(savedTemplate.html, savedTemplate.html ? "Saved template" : "Blank")];
|
|
415
|
-
const loadedSnapshotIndex = aiSession?.messages.length
|
|
416
|
-
? aiSession.snapshotIndex
|
|
417
|
-
: 0;
|
|
418
|
-
const loadedContextImages = aiSession?.contextImages ?? [];
|
|
419
|
-
setMessages(loadedMessages);
|
|
420
|
-
setSnapshots(loadedSnapshots);
|
|
421
|
-
setSnapshotIndex(loadedSnapshotIndex);
|
|
422
|
-
setSessionContextImages(loadedContextImages);
|
|
423
|
-
sessionContextImagesRef.current = loadedContextImages;
|
|
424
|
-
rememberLoadedSession({
|
|
425
|
-
html: savedTemplate.html,
|
|
426
|
-
messages: loadedMessages,
|
|
427
|
-
snapshots: loadedSnapshots,
|
|
428
|
-
snapshotIndex: loadedSnapshotIndex,
|
|
429
|
-
contextImages: loadedContextImages,
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
setDirty(false);
|
|
433
|
-
setLoading(false);
|
|
434
|
-
}, [savedTemplate, applyHtml, rememberLoadedSession, clearPreviewUndo, getSuggestions]);
|
|
435
|
-
useEffect(() => {
|
|
436
|
-
if (open) {
|
|
437
|
-
shouldAutoScrollRef.current = true;
|
|
438
|
-
void loadFromValue();
|
|
439
|
-
}
|
|
440
|
-
}, [open, loadFromValue]);
|
|
441
|
-
const [prevOpen, setPrevOpen] = useState(open);
|
|
442
|
-
if (open !== prevOpen) {
|
|
443
|
-
setPrevOpen(open);
|
|
444
|
-
if (!open) {
|
|
445
|
-
setDirty(false);
|
|
446
|
-
setInput("");
|
|
447
|
-
setContextImages([]);
|
|
448
|
-
setSessionContextImages([]);
|
|
449
|
-
setChatDropActive(false);
|
|
450
|
-
setChatPinSpacerHeight(0);
|
|
451
|
-
pendingScrollPinRef.current = null;
|
|
452
|
-
setMessages([]);
|
|
453
|
-
setSnapshots([]);
|
|
454
|
-
setSnapshotIndex(0);
|
|
455
|
-
setEditingMessageId(null);
|
|
456
|
-
setEditDraft("");
|
|
457
|
-
setSelectMode(false);
|
|
458
|
-
setSelectedBlockIds([]);
|
|
459
|
-
setResubmitConfirmOpen(false);
|
|
460
|
-
setSystemPromptOverride(null);
|
|
461
|
-
setSuggestions(getSuggestions(false));
|
|
462
|
-
setPreviewDropActive(false);
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
useLayoutEffect(() => {
|
|
466
|
-
if (open && html && !loading) {
|
|
467
|
-
mountIframe(html);
|
|
468
|
-
}
|
|
469
|
-
}, [open, html, loading, mountIframe]);
|
|
470
|
-
useEffect(() => {
|
|
471
|
-
const doc = iframeRef.current?.contentDocument;
|
|
472
|
-
if (!doc)
|
|
473
|
-
return;
|
|
474
|
-
doc.body.classList.toggle("ai-select-mode", selectMode);
|
|
475
|
-
syncFlaggedElements(doc, selectedBlockIds);
|
|
476
|
-
}, [selectMode, selectedBlockIds, html]);
|
|
477
|
-
useEffect(() => {
|
|
478
|
-
const el = scrollRef.current;
|
|
479
|
-
if (!el || !open || loading)
|
|
480
|
-
return;
|
|
481
|
-
const onScroll = () => {
|
|
482
|
-
if (suppressScrollTrackingRef.current)
|
|
483
|
-
return;
|
|
484
|
-
shouldAutoScrollRef.current = isChatNearBottom(el);
|
|
485
|
-
};
|
|
486
|
-
el.addEventListener("scroll", onScroll, { passive: true });
|
|
487
|
-
return () => el.removeEventListener("scroll", onScroll);
|
|
488
|
-
}, [open, loading]);
|
|
489
|
-
useLayoutEffect(() => {
|
|
490
|
-
const el = scrollRef.current;
|
|
491
|
-
if (!el)
|
|
492
|
-
return;
|
|
493
|
-
const pinId = pendingScrollPinRef.current;
|
|
494
|
-
if (pinId) {
|
|
495
|
-
const targetScroll = scrollChatMessageToTop(el, pinId);
|
|
496
|
-
if (targetScroll !== null) {
|
|
497
|
-
pendingScrollPinRef.current = null;
|
|
498
|
-
shouldAutoScrollRef.current = true;
|
|
499
|
-
programChatScroll(targetScroll);
|
|
500
|
-
}
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
if (!shouldAutoScrollRef.current)
|
|
504
|
-
return;
|
|
505
|
-
const maxScroll = el.scrollHeight - el.clientHeight;
|
|
506
|
-
const targetScroll = chatPinSpacerHeight > 0
|
|
507
|
-
? Math.min(maxScroll, Math.max(0, el.scrollHeight - chatPinSpacerHeight - el.clientHeight))
|
|
508
|
-
: maxScroll;
|
|
509
|
-
if (targetScroll > el.scrollTop) {
|
|
510
|
-
programChatScroll(targetScroll);
|
|
511
|
-
}
|
|
512
|
-
}, [messages, chatPinSpacerHeight, isStreaming, programChatScroll]);
|
|
513
|
-
dropHandlerRef.current = (event) => {
|
|
514
|
-
const file = Array.from(event.dataTransfer?.files ?? []).find((f) => f.type.startsWith("image/"));
|
|
515
|
-
if (file) {
|
|
516
|
-
void uploadAndInsertImage(file, { x: event.clientX, y: event.clientY });
|
|
517
|
-
}
|
|
518
|
-
};
|
|
519
|
-
pasteHandlerRef.current = (event) => {
|
|
520
|
-
const items = event.clipboardData?.items;
|
|
521
|
-
if (!items)
|
|
522
|
-
return;
|
|
523
|
-
const item = Array.from(items).find((it) => it.kind === "file" && it.type.startsWith("image/"));
|
|
524
|
-
const file = item?.getAsFile();
|
|
525
|
-
if (!file)
|
|
526
|
-
return;
|
|
527
|
-
event.preventDefault();
|
|
528
|
-
void uploadAndInsertImage(file, "end");
|
|
529
|
-
};
|
|
530
|
-
const persistSessionContextImages = useCallback((images) => {
|
|
531
|
-
setSessionContextImages(images);
|
|
532
|
-
sessionContextImagesRef.current = images;
|
|
533
|
-
}, []);
|
|
534
|
-
const registerContextImage = useCallback(async (file) => {
|
|
535
|
-
if (!file.type.startsWith("image/")) {
|
|
536
|
-
reportError(new Error("Only image files can be attached as reference."), "image");
|
|
537
|
-
return null;
|
|
538
|
-
}
|
|
539
|
-
if (sessionContextImagesRef.current.length >= MAX_CONTEXT_IMAGES) {
|
|
540
|
-
reportError(new Error(`You can attach up to ${MAX_CONTEXT_IMAGES} reference images.`), "image");
|
|
541
|
-
return null;
|
|
542
|
-
}
|
|
543
|
-
const prepared = await prepareImageFileForUpload(file);
|
|
544
|
-
const { url } = await onUploadImage(prepared);
|
|
545
|
-
const label = suggestLabelFromFile(prepared);
|
|
546
|
-
const image = {
|
|
547
|
-
id: createId(),
|
|
548
|
-
url,
|
|
549
|
-
name: label,
|
|
550
|
-
};
|
|
551
|
-
const next = [...sessionContextImagesRef.current, image];
|
|
552
|
-
persistSessionContextImages(next);
|
|
553
|
-
return { url, label };
|
|
554
|
-
}, [onUploadImage, persistSessionContextImages, reportError]);
|
|
555
|
-
const queueContextImage = useCallback((image) => {
|
|
556
|
-
setContextImages((prev) => {
|
|
557
|
-
if (prev.some((img) => img.url === image.url))
|
|
558
|
-
return prev;
|
|
559
|
-
return [...prev, image];
|
|
560
|
-
});
|
|
561
|
-
}, []);
|
|
562
|
-
const addContextImageFromFile = useCallback(async (file) => {
|
|
563
|
-
setContextImageBusy(true);
|
|
564
|
-
try {
|
|
565
|
-
const uploaded = await registerContextImage(file);
|
|
566
|
-
if (!uploaded)
|
|
567
|
-
return;
|
|
568
|
-
queueContextImage({ id: createId(), url: uploaded.url, name: uploaded.label });
|
|
569
|
-
reportSuccess("image", "Reference image attached");
|
|
570
|
-
}
|
|
571
|
-
catch (error) {
|
|
572
|
-
reportError(error, "image");
|
|
573
|
-
}
|
|
574
|
-
finally {
|
|
575
|
-
setContextImageBusy(false);
|
|
576
|
-
}
|
|
577
|
-
}, [registerContextImage, queueContextImage, reportError, reportSuccess]);
|
|
578
|
-
const addEditContextImageFromFile = useCallback(async (file) => {
|
|
579
|
-
setContextImageBusy(true);
|
|
580
|
-
try {
|
|
581
|
-
const uploaded = await registerContextImage(file);
|
|
582
|
-
if (!uploaded)
|
|
583
|
-
return;
|
|
584
|
-
setEditContextImages((prev) => [...prev, uploaded]);
|
|
585
|
-
reportSuccess("image", "Reference image attached");
|
|
586
|
-
}
|
|
587
|
-
catch (error) {
|
|
588
|
-
reportError(error, "image");
|
|
589
|
-
}
|
|
590
|
-
finally {
|
|
591
|
-
setContextImageBusy(false);
|
|
592
|
-
}
|
|
593
|
-
}, [registerContextImage, reportError, reportSuccess]);
|
|
594
|
-
const removeEditContextImage = useCallback((url) => {
|
|
595
|
-
setEditContextImages((prev) => prev.filter((img) => img.url !== url));
|
|
596
|
-
}, []);
|
|
597
|
-
const removeContextImage = useCallback(async (url) => {
|
|
598
|
-
if (isStreaming || contextImageBusy)
|
|
599
|
-
return;
|
|
600
|
-
const priorQueue = contextImages;
|
|
601
|
-
const priorSession = sessionContextImagesRef.current;
|
|
602
|
-
const priorMessages = messages;
|
|
603
|
-
const nextQueue = priorQueue.filter((img) => img.url !== url);
|
|
604
|
-
const nextSession = priorSession.filter((img) => img.url !== url);
|
|
605
|
-
setContextImages(nextQueue);
|
|
606
|
-
persistSessionContextImages(nextSession);
|
|
607
|
-
await syncOrphanedImages(priorMessages, priorSession, priorQueue, priorMessages, nextSession, nextQueue);
|
|
608
|
-
}, [isStreaming, contextImageBusy, contextImages, messages, persistSessionContextImages, syncOrphanedImages]);
|
|
609
|
-
const removeMessageContextImage = useCallback(async (messageId, url) => {
|
|
610
|
-
if (isStreaming || contextImageBusy)
|
|
611
|
-
return;
|
|
612
|
-
const priorMessages = messages;
|
|
613
|
-
const priorSession = sessionContextImagesRef.current;
|
|
614
|
-
const nextMessages = messages.map((msg) => {
|
|
615
|
-
if (msg.id !== messageId || !msg.contextImages)
|
|
616
|
-
return msg;
|
|
617
|
-
const nextImages = msg.contextImages.filter((img) => img.url !== url);
|
|
618
|
-
return {
|
|
619
|
-
...msg,
|
|
620
|
-
contextImages: nextImages.length ? nextImages : undefined,
|
|
621
|
-
};
|
|
622
|
-
});
|
|
623
|
-
setMessages(nextMessages);
|
|
624
|
-
await syncOrphanedImages(priorMessages, priorSession, contextImages, nextMessages, priorSession, contextImages);
|
|
625
|
-
}, [isStreaming, contextImageBusy, messages, contextImages, syncOrphanedImages]);
|
|
626
|
-
const handleChatPaste = useCallback((event) => {
|
|
627
|
-
const items = event.clipboardData?.items;
|
|
628
|
-
if (!items)
|
|
629
|
-
return;
|
|
630
|
-
const item = Array.from(items).find((it) => it.kind === "file" && it.type.startsWith("image/"));
|
|
631
|
-
const file = item?.getAsFile();
|
|
632
|
-
if (!file)
|
|
633
|
-
return;
|
|
634
|
-
event.preventDefault();
|
|
635
|
-
void addContextImageFromFile(file);
|
|
636
|
-
}, [addContextImageFromFile]);
|
|
637
|
-
const handleChatDragOver = useCallback((event) => {
|
|
638
|
-
if (isStreaming || contextImageBusy)
|
|
639
|
-
return;
|
|
640
|
-
if (!event.dataTransfer.types.includes("Files"))
|
|
641
|
-
return;
|
|
642
|
-
event.preventDefault();
|
|
643
|
-
event.dataTransfer.dropEffect = "copy";
|
|
644
|
-
setChatDropActive(true);
|
|
645
|
-
}, [isStreaming, contextImageBusy]);
|
|
646
|
-
const handleChatDragLeave = useCallback((event) => {
|
|
647
|
-
if (event.currentTarget.contains(event.relatedTarget))
|
|
648
|
-
return;
|
|
649
|
-
setChatDropActive(false);
|
|
650
|
-
}, []);
|
|
651
|
-
const handleChatDrop = useCallback((event) => {
|
|
652
|
-
if (isStreaming || contextImageBusy)
|
|
653
|
-
return;
|
|
654
|
-
event.preventDefault();
|
|
655
|
-
setChatDropActive(false);
|
|
656
|
-
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith("image/"));
|
|
657
|
-
if (file)
|
|
658
|
-
void addContextImageFromFile(file);
|
|
659
|
-
}, [isStreaming, contextImageBusy, addContextImageFromFile]);
|
|
660
|
-
const buildStoredRecord = useCallback(async (cleanHtml) => {
|
|
661
|
-
const aiSession = {
|
|
662
|
-
messages: toPersisted(messages),
|
|
663
|
-
snapshots,
|
|
664
|
-
snapshotIndex,
|
|
665
|
-
contextImages: sessionContextImagesRef.current,
|
|
666
|
-
};
|
|
667
|
-
const now = Date.now();
|
|
668
|
-
return {
|
|
669
|
-
id: savedTemplate?.id ?? crypto.randomUUID(),
|
|
670
|
-
name: savedTemplate?.name ?? name,
|
|
671
|
-
html: cleanHtml,
|
|
672
|
-
createdAt: savedTemplate?.createdAt ?? now,
|
|
673
|
-
updatedAt: now,
|
|
674
|
-
aiSession,
|
|
675
|
-
};
|
|
676
|
-
}, [messages, snapshots, snapshotIndex, savedTemplate, name]);
|
|
677
|
-
const runChatTurn = useCallback(async (text, images, priorMessages, priorSnapshots, priorIndex, blockIds) => {
|
|
678
|
-
const now = Date.now();
|
|
679
|
-
const userMessage = {
|
|
680
|
-
id: createId(),
|
|
681
|
-
role: "user",
|
|
682
|
-
content: text,
|
|
683
|
-
contextImages: images.length ? images : undefined,
|
|
684
|
-
selectedBlockIds: blockIds.length ? blockIds : undefined,
|
|
685
|
-
timestamp: now,
|
|
686
|
-
};
|
|
687
|
-
const assistantId = createId();
|
|
688
|
-
inFlightRef.current = { priorMessages, userText: text };
|
|
689
|
-
const controller = new AbortController();
|
|
690
|
-
abortRef.current = controller;
|
|
691
|
-
pendingScrollPinRef.current = userMessage.id;
|
|
692
|
-
setChatPinSpacerHeight(computeChatPinSpacerHeight(scrollRef.current));
|
|
693
|
-
setMessages([
|
|
694
|
-
...priorMessages,
|
|
695
|
-
userMessage,
|
|
696
|
-
{
|
|
697
|
-
id: assistantId,
|
|
698
|
-
role: "assistant",
|
|
699
|
-
content: phaseLabel("thinking"),
|
|
700
|
-
phase: "thinking",
|
|
701
|
-
pending: true,
|
|
702
|
-
timestamp: now,
|
|
703
|
-
},
|
|
704
|
-
]);
|
|
705
|
-
setIsStreaming(true);
|
|
706
|
-
const patchAssistant = (patch) => setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, ...patch } : m)));
|
|
707
|
-
try {
|
|
708
|
-
const selectedBlocks = blockIds.length ? getSelectedBlocks() : [];
|
|
709
|
-
syncFromIframe(false);
|
|
710
|
-
let previewScreenshot;
|
|
711
|
-
const isFollowUp = priorMessages.length > 0 && htmlRef.current.trim();
|
|
712
|
-
if (isFollowUp) {
|
|
713
|
-
try {
|
|
714
|
-
const captured = await Promise.race([
|
|
715
|
-
captureEmailPreviewScreenshot(stripEditorChrome(htmlRef.current)),
|
|
716
|
-
new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
|
|
717
|
-
]);
|
|
718
|
-
if (captured)
|
|
719
|
-
previewScreenshot = captured;
|
|
720
|
-
}
|
|
721
|
-
catch {
|
|
722
|
-
/* best effort */
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
const res = await fetch(streamRoute, {
|
|
726
|
-
method: "POST",
|
|
727
|
-
headers: { "Content-Type": "application/json" },
|
|
728
|
-
signal: controller.signal,
|
|
729
|
-
body: JSON.stringify({
|
|
730
|
-
messages: [
|
|
731
|
-
...priorMessages.map((m) => ({
|
|
732
|
-
role: m.role,
|
|
733
|
-
content: m.content,
|
|
734
|
-
contextImages: m.contextImages?.map((img) => ({
|
|
735
|
-
url: img.url,
|
|
736
|
-
label: img.name ?? img.url,
|
|
737
|
-
})),
|
|
738
|
-
})),
|
|
739
|
-
{
|
|
740
|
-
role: "user",
|
|
741
|
-
content: text,
|
|
742
|
-
contextImages: images.length
|
|
743
|
-
? images.map((img) => ({
|
|
744
|
-
url: img.url,
|
|
745
|
-
label: img.name ?? img.url,
|
|
746
|
-
}))
|
|
747
|
-
: undefined,
|
|
748
|
-
},
|
|
749
|
-
],
|
|
750
|
-
systemPrompt: effectiveSystemPrompt,
|
|
751
|
-
sessionContextImages: sessionContextImagesRef.current.map((img) => ({
|
|
752
|
-
url: img.url,
|
|
753
|
-
label: img.name ?? img.url,
|
|
754
|
-
})),
|
|
755
|
-
previewScreenshot,
|
|
756
|
-
currentHtml: htmlRef.current || undefined,
|
|
757
|
-
selectedBlocks: selectedBlocks.length ? selectedBlocks : undefined,
|
|
758
|
-
model: selectedModel,
|
|
759
|
-
}),
|
|
760
|
-
});
|
|
761
|
-
if (!res.ok || !res.body) {
|
|
762
|
-
const data = (await res.json().catch(() => null));
|
|
763
|
-
throw new Error(data?.error ?? "Failed to generate email.");
|
|
764
|
-
}
|
|
765
|
-
const reader = res.body.getReader();
|
|
766
|
-
const decoder = new TextDecoder();
|
|
767
|
-
let buffer = "";
|
|
768
|
-
let partial = undefined;
|
|
769
|
-
for (;;) {
|
|
770
|
-
const { done, value: chunk } = await reader.read();
|
|
771
|
-
if (done)
|
|
772
|
-
break;
|
|
773
|
-
buffer += decoder.decode(chunk, { stream: true });
|
|
774
|
-
partial = await parseStructuredStream(buffer);
|
|
775
|
-
const phase = detectPhase(partial);
|
|
776
|
-
const { narration } = extractStreamParts(partial);
|
|
777
|
-
patchAssistant({
|
|
778
|
-
phase,
|
|
779
|
-
content: narration.trim() || phaseLabel(phase),
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
partial = await parseStructuredStream(buffer);
|
|
783
|
-
const phase = detectPhase(partial);
|
|
784
|
-
const { narration, suggestions: nextSuggestions, html: htmlPart } = extractStreamParts(partial);
|
|
785
|
-
const finalHtml = sanitizeEmailHtml(stripHtmlCodeFences(htmlPart));
|
|
786
|
-
if (nextSuggestions.length >= 2) {
|
|
787
|
-
setSuggestions(nextSuggestions);
|
|
788
|
-
}
|
|
789
|
-
else if (finalHtml) {
|
|
790
|
-
setSuggestions(defaultSuggestions(true));
|
|
791
|
-
}
|
|
792
|
-
let nextSnapshots = priorSnapshots.slice(0, priorIndex + 1);
|
|
793
|
-
let nextIndex = priorIndex;
|
|
794
|
-
let snapshotId;
|
|
795
|
-
if (finalHtml) {
|
|
796
|
-
clearPreviewUndo();
|
|
797
|
-
applyHtml(finalHtml);
|
|
798
|
-
const label = narration.split("\n")[0]?.replace(/^-\s*/, "").slice(0, 60) || "AI update";
|
|
799
|
-
const snap = snapshot(finalHtml, label);
|
|
800
|
-
nextSnapshots = [...nextSnapshots, snap];
|
|
801
|
-
nextIndex = nextSnapshots.length - 1;
|
|
802
|
-
snapshotId = snap.id;
|
|
803
|
-
setSnapshots(nextSnapshots);
|
|
804
|
-
setSnapshotIndex(nextIndex);
|
|
805
|
-
}
|
|
806
|
-
const finalMessages = [
|
|
807
|
-
...priorMessages,
|
|
808
|
-
userMessage,
|
|
809
|
-
{
|
|
810
|
-
id: assistantId,
|
|
811
|
-
role: "assistant",
|
|
812
|
-
content: narration.trim() ||
|
|
813
|
-
(finalHtml
|
|
814
|
-
? "Done — changes applied."
|
|
815
|
-
: "I couldn't produce an email update. Try rephrasing."),
|
|
816
|
-
phase,
|
|
817
|
-
pending: false,
|
|
818
|
-
appliedHtml: !!finalHtml,
|
|
819
|
-
snapshotId,
|
|
820
|
-
timestamp: Date.now(),
|
|
821
|
-
},
|
|
822
|
-
];
|
|
823
|
-
setMessages(finalMessages);
|
|
824
|
-
setSelectedBlockIds([]);
|
|
825
|
-
}
|
|
826
|
-
catch (error) {
|
|
827
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
828
|
-
const ctx = inFlightRef.current;
|
|
829
|
-
if (ctx) {
|
|
830
|
-
setMessages(ctx.priorMessages);
|
|
831
|
-
setInput(ctx.userText);
|
|
832
|
-
}
|
|
833
|
-
pendingScrollPinRef.current = null;
|
|
834
|
-
return;
|
|
835
|
-
}
|
|
836
|
-
const err = error instanceof Error ? error : new Error("Failed to generate email.");
|
|
837
|
-
reportError(err, "stream");
|
|
838
|
-
patchAssistant({
|
|
839
|
-
content: err.message,
|
|
840
|
-
pending: false,
|
|
841
|
-
error: true,
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
finally {
|
|
845
|
-
inFlightRef.current = null;
|
|
846
|
-
abortRef.current = null;
|
|
847
|
-
setIsStreaming(false);
|
|
848
|
-
setChatPinSpacerHeight(0);
|
|
849
|
-
}
|
|
850
|
-
}, [
|
|
851
|
-
effectiveSystemPrompt,
|
|
852
|
-
applyHtml,
|
|
853
|
-
getSelectedBlocks,
|
|
854
|
-
syncFromIframe,
|
|
855
|
-
clearPreviewUndo,
|
|
856
|
-
streamRoute,
|
|
857
|
-
selectedModel,
|
|
858
|
-
reportError,
|
|
859
|
-
]);
|
|
860
|
-
const cancelStream = useCallback(() => {
|
|
861
|
-
abortRef.current?.abort();
|
|
862
|
-
}, []);
|
|
863
|
-
const handleSend = useCallback(() => {
|
|
864
|
-
const trimmed = input.trim();
|
|
865
|
-
const images = contextImages;
|
|
866
|
-
const text = trimmed ||
|
|
867
|
-
(images.length
|
|
868
|
-
? "Create an email inspired by the attached reference image(s)."
|
|
869
|
-
: "");
|
|
870
|
-
if (!text || isStreaming)
|
|
871
|
-
return;
|
|
872
|
-
if (!selectedModel) {
|
|
873
|
-
reportError(new Error("No model configured."), "stream");
|
|
874
|
-
return;
|
|
875
|
-
}
|
|
876
|
-
if (editingMessageId) {
|
|
877
|
-
setEditingMessageId(null);
|
|
878
|
-
setEditDraft("");
|
|
879
|
-
}
|
|
880
|
-
setInput("");
|
|
881
|
-
setContextImages([]);
|
|
882
|
-
void runChatTurn(text, images, messages, snapshots, snapshotIndex, selectedBlockIds);
|
|
883
|
-
}, [
|
|
884
|
-
input,
|
|
885
|
-
contextImages,
|
|
886
|
-
isStreaming,
|
|
887
|
-
editingMessageId,
|
|
888
|
-
messages,
|
|
889
|
-
snapshots,
|
|
890
|
-
snapshotIndex,
|
|
891
|
-
selectedBlockIds,
|
|
892
|
-
runChatTurn,
|
|
893
|
-
reportError,
|
|
894
|
-
]);
|
|
895
|
-
const canUndoChat = useMemo(() => {
|
|
896
|
-
const n = messages.length;
|
|
897
|
-
if (n < 2)
|
|
898
|
-
return false;
|
|
899
|
-
const last = messages[n - 1];
|
|
900
|
-
const prior = messages[n - 2];
|
|
901
|
-
return last?.role === "assistant" && !last.pending && prior?.role === "user";
|
|
902
|
-
}, [messages]);
|
|
903
|
-
const canGoBack = !isStreaming && (previewUndoCount > 0 || canUndoChat);
|
|
904
|
-
const executeBack = useCallback(async () => {
|
|
905
|
-
if (!canUndoChat)
|
|
906
|
-
return;
|
|
907
|
-
clearPreviewUndo();
|
|
908
|
-
const lastUserIdx = messages.length - 2;
|
|
909
|
-
const priorMessages = messages.slice(0, lastUserIdx);
|
|
910
|
-
const priorSession = sessionContextImagesRef.current;
|
|
911
|
-
const priorPending = contextImages;
|
|
912
|
-
let snapIdx = 0;
|
|
913
|
-
let targetHtml = snapshots[0]?.html ?? "";
|
|
914
|
-
const priorSnapId = [...priorMessages]
|
|
915
|
-
.reverse()
|
|
916
|
-
.find((m) => m.role === "assistant" && m.snapshotId)?.snapshotId;
|
|
917
|
-
if (priorSnapId) {
|
|
918
|
-
snapIdx = snapshots.findIndex((s) => s.id === priorSnapId);
|
|
919
|
-
if (snapIdx >= 0)
|
|
920
|
-
targetHtml = snapshots[snapIdx].html;
|
|
921
|
-
}
|
|
922
|
-
else if (priorMessages.length === 0) {
|
|
923
|
-
targetHtml = snapshots[0]?.html ?? "";
|
|
924
|
-
snapIdx = 0;
|
|
925
|
-
}
|
|
926
|
-
if (snapIdx < 0)
|
|
927
|
-
snapIdx = 0;
|
|
928
|
-
const nextSnapshots = snapshots.slice(0, snapIdx + 1);
|
|
929
|
-
applyHtml(targetHtml);
|
|
930
|
-
setSnapshotIndex(snapIdx);
|
|
931
|
-
setSnapshots(nextSnapshots);
|
|
932
|
-
setMessages(priorMessages);
|
|
933
|
-
setContextImages([]);
|
|
934
|
-
setSelectedBlockIds([]);
|
|
935
|
-
setEditingMessageId(null);
|
|
936
|
-
setEditDraft("");
|
|
937
|
-
await syncOrphanedImages(messages, priorSession, priorPending, priorMessages, priorSession, []);
|
|
938
|
-
reportSuccess("undo", "Undid last message");
|
|
939
|
-
}, [
|
|
940
|
-
canUndoChat,
|
|
941
|
-
messages,
|
|
942
|
-
snapshots,
|
|
943
|
-
contextImages,
|
|
944
|
-
applyHtml,
|
|
945
|
-
clearPreviewUndo,
|
|
946
|
-
syncOrphanedImages,
|
|
947
|
-
reportSuccess,
|
|
948
|
-
]);
|
|
949
|
-
const requestBack = useCallback(() => {
|
|
950
|
-
if (isStreaming)
|
|
951
|
-
return;
|
|
952
|
-
if (undoPreviewEdit())
|
|
953
|
-
return;
|
|
954
|
-
if (!canUndoChat)
|
|
955
|
-
return;
|
|
956
|
-
setBackConfirmOpen(true);
|
|
957
|
-
}, [canUndoChat, isStreaming, undoPreviewEdit]);
|
|
958
|
-
const confirmBack = useCallback(() => {
|
|
959
|
-
setBackConfirmOpen(false);
|
|
960
|
-
void executeBack();
|
|
961
|
-
}, [executeBack]);
|
|
962
|
-
const cancelBack = useCallback(() => setBackConfirmOpen(false), []);
|
|
963
|
-
const backConfirmSummary = useMemo(() => {
|
|
964
|
-
if (!canUndoChat)
|
|
965
|
-
return "";
|
|
966
|
-
const lastUser = messages[messages.length - 2];
|
|
967
|
-
const preview = lastUser?.content?.trim().slice(0, 100);
|
|
968
|
-
const hadImages = !!lastUser?.contextImages?.length;
|
|
969
|
-
if (preview && hadImages) {
|
|
970
|
-
return `"${preview}${lastUser.content.length > 100 ? "…" : ""}" and its reference image(s) will be removed.`;
|
|
971
|
-
}
|
|
972
|
-
if (preview) {
|
|
973
|
-
return `"${preview}${lastUser.content.length > 100 ? "…" : ""}" will be removed.`;
|
|
974
|
-
}
|
|
975
|
-
if (hadImages)
|
|
976
|
-
return "The last message and its reference image(s) will be removed.";
|
|
977
|
-
return "The last message will be removed and the preview will go back one step.";
|
|
978
|
-
}, [canUndoChat, messages]);
|
|
979
|
-
const startEditing = useCallback((id) => {
|
|
980
|
-
const message = messages.find((m) => m.id === id);
|
|
981
|
-
if (isStreaming || message?.role !== "user")
|
|
982
|
-
return;
|
|
983
|
-
setEditingMessageId(id);
|
|
984
|
-
setEditDraft(message.content);
|
|
985
|
-
setEditContextImages((message.contextImages ?? []).map((img) => ({
|
|
986
|
-
url: img.url,
|
|
987
|
-
label: img.name ?? img.url,
|
|
988
|
-
})));
|
|
989
|
-
}, [isStreaming, messages]);
|
|
990
|
-
const cancelEditing = useCallback(() => {
|
|
991
|
-
setEditingMessageId(null);
|
|
992
|
-
setEditDraft("");
|
|
993
|
-
setEditContextImages([]);
|
|
994
|
-
}, []);
|
|
995
|
-
const submitEdit = useCallback(() => {
|
|
996
|
-
const text = editDraft.trim();
|
|
997
|
-
if (!text || !editingMessageId || isStreaming)
|
|
998
|
-
return;
|
|
999
|
-
const messageIndex = messages.findIndex((m) => m.id === editingMessageId);
|
|
1000
|
-
if (messageIndex === -1 || messages[messageIndex]?.role !== "user")
|
|
1001
|
-
return;
|
|
1002
|
-
const priorMessages = messages.slice(0, messageIndex);
|
|
1003
|
-
const priorSnapshotId = [...priorMessages]
|
|
1004
|
-
.reverse()
|
|
1005
|
-
.find((m) => m.role === "assistant" && m.snapshotId)?.snapshotId;
|
|
1006
|
-
const restoreIndex = priorSnapshotId
|
|
1007
|
-
? snapshots.findIndex((s) => s.id === priorSnapshotId)
|
|
1008
|
-
: 0;
|
|
1009
|
-
const priorIndex = restoreIndex >= 0 ? restoreIndex : 0;
|
|
1010
|
-
const priorSnapshots = snapshots.slice(0, priorIndex + 1);
|
|
1011
|
-
clearPreviewUndo();
|
|
1012
|
-
applyHtml(snapshots[priorIndex]?.html ?? "");
|
|
1013
|
-
setSnapshots(priorSnapshots);
|
|
1014
|
-
setSnapshotIndex(priorIndex);
|
|
1015
|
-
setMessages(priorMessages);
|
|
1016
|
-
setEditingMessageId(null);
|
|
1017
|
-
setEditDraft("");
|
|
1018
|
-
setEditContextImages([]);
|
|
1019
|
-
setSelectedBlockIds([]);
|
|
1020
|
-
void syncOrphanedImages(messages, sessionContextImagesRef.current, contextImages, priorMessages, sessionContextImagesRef.current, []).then(() => runChatTurn(text, editContextImages.map((img) => ({
|
|
1021
|
-
id: createId(),
|
|
1022
|
-
url: img.url,
|
|
1023
|
-
name: img.label,
|
|
1024
|
-
})), priorMessages, priorSnapshots, priorIndex, []));
|
|
1025
|
-
}, [
|
|
1026
|
-
editDraft,
|
|
1027
|
-
editingMessageId,
|
|
1028
|
-
editContextImages,
|
|
1029
|
-
isStreaming,
|
|
1030
|
-
messages,
|
|
1031
|
-
snapshots,
|
|
1032
|
-
contextImages,
|
|
1033
|
-
applyHtml,
|
|
1034
|
-
runChatTurn,
|
|
1035
|
-
syncOrphanedImages,
|
|
1036
|
-
clearPreviewUndo,
|
|
1037
|
-
]);
|
|
1038
|
-
const requestSubmitEdit = useCallback(() => {
|
|
1039
|
-
if (!editDraft.trim() || !editingMessageId || isStreaming)
|
|
1040
|
-
return;
|
|
1041
|
-
setResubmitConfirmOpen(true);
|
|
1042
|
-
}, [editDraft, editingMessageId, isStreaming]);
|
|
1043
|
-
const confirmSubmitEdit = useCallback(() => {
|
|
1044
|
-
setResubmitConfirmOpen(false);
|
|
1045
|
-
submitEdit();
|
|
1046
|
-
}, [submitEdit]);
|
|
1047
|
-
const cancelSubmitConfirm = useCallback(() => setResubmitConfirmOpen(false), []);
|
|
1048
|
-
const canRevertToCheckpoint = useCallback((userMessageId) => {
|
|
1049
|
-
const idx = messages.findIndex((m) => m.id === userMessageId);
|
|
1050
|
-
if (idx === -1 || messages[idx]?.role !== "user")
|
|
1051
|
-
return false;
|
|
1052
|
-
if (messages[idx + 1]?.pending)
|
|
1053
|
-
return false;
|
|
1054
|
-
return idx + 2 < messages.length;
|
|
1055
|
-
}, [messages]);
|
|
1056
|
-
const revertToCheckpoint = useCallback(async (userMessageId) => {
|
|
1057
|
-
if (isStreaming)
|
|
1058
|
-
return;
|
|
1059
|
-
const messageIndex = messages.findIndex((m) => m.id === userMessageId);
|
|
1060
|
-
if (messageIndex === -1 || messages[messageIndex]?.role !== "user")
|
|
1061
|
-
return;
|
|
1062
|
-
if (!canRevertToCheckpoint(userMessageId))
|
|
1063
|
-
return;
|
|
1064
|
-
const assistantMsg = messages[messageIndex + 1];
|
|
1065
|
-
if (!assistantMsg || assistantMsg.role !== "assistant")
|
|
1066
|
-
return;
|
|
1067
|
-
let snapIdx = 0;
|
|
1068
|
-
let targetHtml = snapshots[0]?.html ?? "";
|
|
1069
|
-
if (assistantMsg.snapshotId) {
|
|
1070
|
-
snapIdx = snapshots.findIndex((s) => s.id === assistantMsg.snapshotId);
|
|
1071
|
-
if (snapIdx >= 0)
|
|
1072
|
-
targetHtml = snapshots[snapIdx].html;
|
|
1073
|
-
}
|
|
1074
|
-
else {
|
|
1075
|
-
const prior = messages.slice(0, messageIndex);
|
|
1076
|
-
const priorSnapId = [...prior]
|
|
1077
|
-
.reverse()
|
|
1078
|
-
.find((m) => m.role === "assistant" && m.snapshotId)?.snapshotId;
|
|
1079
|
-
if (priorSnapId) {
|
|
1080
|
-
snapIdx = snapshots.findIndex((s) => s.id === priorSnapId);
|
|
1081
|
-
if (snapIdx >= 0)
|
|
1082
|
-
targetHtml = snapshots[snapIdx].html;
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
if (snapIdx < 0)
|
|
1086
|
-
snapIdx = 0;
|
|
1087
|
-
const nextMessages = messages.slice(0, messageIndex + 2);
|
|
1088
|
-
const nextSnapshots = snapshots.slice(0, snapIdx + 1);
|
|
1089
|
-
const priorSession = sessionContextImagesRef.current;
|
|
1090
|
-
clearPreviewUndo();
|
|
1091
|
-
applyHtml(targetHtml);
|
|
1092
|
-
setSnapshotIndex(snapIdx);
|
|
1093
|
-
setSnapshots(nextSnapshots);
|
|
1094
|
-
setMessages(nextMessages);
|
|
1095
|
-
setSelectedBlockIds([]);
|
|
1096
|
-
setEditingMessageId(null);
|
|
1097
|
-
setEditDraft("");
|
|
1098
|
-
await syncOrphanedImages(messages, priorSession, contextImages, nextMessages, priorSession, contextImages);
|
|
1099
|
-
reportSuccess("revert", "Reverted to checkpoint");
|
|
1100
|
-
}, [
|
|
1101
|
-
isStreaming,
|
|
1102
|
-
messages,
|
|
1103
|
-
snapshots,
|
|
1104
|
-
contextImages,
|
|
1105
|
-
canRevertToCheckpoint,
|
|
1106
|
-
applyHtml,
|
|
1107
|
-
clearPreviewUndo,
|
|
1108
|
-
syncOrphanedImages,
|
|
1109
|
-
reportSuccess,
|
|
1110
|
-
]);
|
|
1111
|
-
const requestRevertToCheckpoint = useCallback((userMessageId) => {
|
|
1112
|
-
if (isStreaming || !canRevertToCheckpoint(userMessageId))
|
|
1113
|
-
return;
|
|
1114
|
-
setRevertConfirmMessageId(userMessageId);
|
|
1115
|
-
setRevertConfirmOpen(true);
|
|
1116
|
-
}, [isStreaming, canRevertToCheckpoint]);
|
|
1117
|
-
const confirmRevertToCheckpoint = useCallback(() => {
|
|
1118
|
-
const messageId = revertConfirmMessageId;
|
|
1119
|
-
setRevertConfirmOpen(false);
|
|
1120
|
-
setRevertConfirmMessageId(null);
|
|
1121
|
-
if (messageId)
|
|
1122
|
-
void revertToCheckpoint(messageId);
|
|
1123
|
-
}, [revertConfirmMessageId, revertToCheckpoint]);
|
|
1124
|
-
const cancelRevertConfirm = useCallback(() => {
|
|
1125
|
-
setRevertConfirmOpen(false);
|
|
1126
|
-
setRevertConfirmMessageId(null);
|
|
1127
|
-
}, []);
|
|
1128
|
-
const hasSessionChanges = useMemo(() => {
|
|
1129
|
-
const initial = loadedSessionRef.current;
|
|
1130
|
-
if (!initial) {
|
|
1131
|
-
return (dirty ||
|
|
1132
|
-
messages.length > 0 ||
|
|
1133
|
-
!!input.trim() ||
|
|
1134
|
-
contextImages.length > 0 ||
|
|
1135
|
-
systemPromptOverride !== null);
|
|
1136
|
-
}
|
|
1137
|
-
const currentHtml = stripEditorChrome(html);
|
|
1138
|
-
return (dirty ||
|
|
1139
|
-
systemPromptOverride !== null ||
|
|
1140
|
-
!!input.trim() ||
|
|
1141
|
-
contextImages.length > 0 ||
|
|
1142
|
-
currentHtml !== initial.html ||
|
|
1143
|
-
JSON.stringify(toPersisted(messages)) !== JSON.stringify(initial.messages) ||
|
|
1144
|
-
JSON.stringify(snapshots) !== JSON.stringify(initial.snapshots) ||
|
|
1145
|
-
snapshotIndex !== initial.snapshotIndex ||
|
|
1146
|
-
JSON.stringify(sessionContextImages) !== JSON.stringify(initial.contextImages));
|
|
1147
|
-
}, [
|
|
1148
|
-
dirty,
|
|
1149
|
-
html,
|
|
1150
|
-
messages,
|
|
1151
|
-
snapshots,
|
|
1152
|
-
snapshotIndex,
|
|
1153
|
-
sessionContextImages,
|
|
1154
|
-
input,
|
|
1155
|
-
contextImages,
|
|
1156
|
-
systemPromptOverride,
|
|
1157
|
-
]);
|
|
1158
|
-
const handleSave = useCallback(async () => {
|
|
1159
|
-
syncFromIframe(false);
|
|
1160
|
-
const current = htmlRef.current;
|
|
1161
|
-
const cleanHtml = current.trim() ? stripEditorChrome(current) : "";
|
|
1162
|
-
if (!cleanHtml && !hasSessionChanges) {
|
|
1163
|
-
reportError(new Error("Nothing to save."), "save");
|
|
1164
|
-
return false;
|
|
1165
|
-
}
|
|
1166
|
-
if (cleanHtml) {
|
|
1167
|
-
const mergeError = getMergeTagError(cleanHtml, mergeTags);
|
|
1168
|
-
if (mergeError) {
|
|
1169
|
-
reportError(new Error(mergeError), "save");
|
|
1170
|
-
return false;
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
setSaving(true);
|
|
1174
|
-
try {
|
|
1175
|
-
const record = await buildStoredRecord(cleanHtml);
|
|
1176
|
-
await onSave(record);
|
|
1177
|
-
rememberLoadedSession({
|
|
1178
|
-
html: cleanHtml,
|
|
1179
|
-
messages: toPersisted(messages),
|
|
1180
|
-
snapshots,
|
|
1181
|
-
snapshotIndex,
|
|
1182
|
-
contextImages: sessionContextImagesRef.current,
|
|
1183
|
-
});
|
|
1184
|
-
setDirty(false);
|
|
1185
|
-
reportSuccess("save", cleanHtml ? "Template saved" : "Template cleared");
|
|
1186
|
-
return true;
|
|
1187
|
-
}
|
|
1188
|
-
catch (error) {
|
|
1189
|
-
reportError(error, "save");
|
|
1190
|
-
return false;
|
|
1191
|
-
}
|
|
1192
|
-
finally {
|
|
1193
|
-
setSaving(false);
|
|
1194
|
-
}
|
|
1195
|
-
}, [
|
|
1196
|
-
syncFromIframe,
|
|
1197
|
-
hasSessionChanges,
|
|
1198
|
-
mergeTags,
|
|
1199
|
-
messages,
|
|
1200
|
-
snapshots,
|
|
1201
|
-
snapshotIndex,
|
|
1202
|
-
buildStoredRecord,
|
|
1203
|
-
onSave,
|
|
1204
|
-
rememberLoadedSession,
|
|
1205
|
-
reportError,
|
|
1206
|
-
reportSuccess,
|
|
1207
|
-
]);
|
|
1208
|
-
const resetSystemPrompt = useCallback(() => setSystemPromptOverride(null), []);
|
|
1209
|
-
const applySystemPrompt = useCallback((next) => {
|
|
1210
|
-
const trimmed = next.trim();
|
|
1211
|
-
if (!trimmed) {
|
|
1212
|
-
reportError(new Error("System prompt cannot be empty."), "stream");
|
|
1213
|
-
return false;
|
|
1214
|
-
}
|
|
1215
|
-
setSystemPromptOverride(trimmed === resolvedSystemPrompt.trim() ? null : trimmed);
|
|
1216
|
-
return true;
|
|
1217
|
-
}, [resolvedSystemPrompt, reportError]);
|
|
1218
|
-
const handleRestart = useCallback(async () => {
|
|
1219
|
-
const priorMessages = messages;
|
|
1220
|
-
const priorSession = sessionContextImagesRef.current;
|
|
1221
|
-
const priorPending = contextImages;
|
|
1222
|
-
clearPreviewUndo();
|
|
1223
|
-
applyHtml("");
|
|
1224
|
-
setMessages([]);
|
|
1225
|
-
setSnapshots([snapshot("", "Blank")]);
|
|
1226
|
-
setSnapshotIndex(0);
|
|
1227
|
-
setInput("");
|
|
1228
|
-
setContextImages([]);
|
|
1229
|
-
setSessionContextImages([]);
|
|
1230
|
-
sessionContextImagesRef.current = [];
|
|
1231
|
-
setSystemPromptOverride(null);
|
|
1232
|
-
setEditingMessageId(null);
|
|
1233
|
-
setEditDraft("");
|
|
1234
|
-
setSelectMode(false);
|
|
1235
|
-
setSelectedBlockIds([]);
|
|
1236
|
-
setSuggestions(getSuggestions(false));
|
|
1237
|
-
setDirty(true);
|
|
1238
|
-
await syncOrphanedImages(priorMessages, priorSession, priorPending, [], [], []);
|
|
1239
|
-
}, [messages, contextImages, applyHtml, clearPreviewUndo, syncOrphanedImages]);
|
|
1240
|
-
return {
|
|
1241
|
-
iframeRef,
|
|
1242
|
-
scrollRef,
|
|
1243
|
-
chatPinSpacerHeight,
|
|
1244
|
-
hasHtml: !!html,
|
|
1245
|
-
loading,
|
|
1246
|
-
saving,
|
|
1247
|
-
hasSessionChanges,
|
|
1248
|
-
getCleanHtml,
|
|
1249
|
-
messages,
|
|
1250
|
-
input,
|
|
1251
|
-
setInput,
|
|
1252
|
-
contextImages,
|
|
1253
|
-
contextImageBusy,
|
|
1254
|
-
removeContextImage,
|
|
1255
|
-
removeMessageContextImage,
|
|
1256
|
-
addContextImageFromFile,
|
|
1257
|
-
canRemoveContextImages: !isStreaming && !contextImageBusy,
|
|
1258
|
-
chatDropActive,
|
|
1259
|
-
handleChatPaste,
|
|
1260
|
-
handleChatDragOver,
|
|
1261
|
-
handleChatDragLeave,
|
|
1262
|
-
handleChatDrop,
|
|
1263
|
-
isStreaming,
|
|
1264
|
-
editingMessageId,
|
|
1265
|
-
editDraft,
|
|
1266
|
-
setEditDraft,
|
|
1267
|
-
editContextImages,
|
|
1268
|
-
addEditContextImageFromFile,
|
|
1269
|
-
removeEditContextImage,
|
|
1270
|
-
startEditing,
|
|
1271
|
-
cancelEditing,
|
|
1272
|
-
requestSubmitEdit,
|
|
1273
|
-
confirmSubmitEdit,
|
|
1274
|
-
cancelSubmitConfirm,
|
|
1275
|
-
resubmitConfirmOpen,
|
|
1276
|
-
handleSend,
|
|
1277
|
-
cancelStream,
|
|
1278
|
-
canRevertToCheckpoint,
|
|
1279
|
-
requestRevertToCheckpoint,
|
|
1280
|
-
confirmRevertToCheckpoint,
|
|
1281
|
-
cancelRevertConfirm,
|
|
1282
|
-
revertConfirmOpen,
|
|
1283
|
-
suggestions,
|
|
1284
|
-
defaultSystemPrompt: resolvedSystemPrompt,
|
|
1285
|
-
effectiveSystemPrompt,
|
|
1286
|
-
applySystemPrompt,
|
|
1287
|
-
resetSystemPrompt,
|
|
1288
|
-
selectMode,
|
|
1289
|
-
exitSelectMode,
|
|
1290
|
-
toggleSelectMode,
|
|
1291
|
-
selectedBlockIds,
|
|
1292
|
-
toggleBlockSelection,
|
|
1293
|
-
canGoBack,
|
|
1294
|
-
requestBack,
|
|
1295
|
-
confirmBack,
|
|
1296
|
-
cancelBack,
|
|
1297
|
-
backConfirmOpen,
|
|
1298
|
-
backConfirmSummary,
|
|
1299
|
-
previewDropActive,
|
|
1300
|
-
handleSave,
|
|
1301
|
-
handleRestart,
|
|
1302
|
-
canRestart: (!!html || messages.length > 0) && !isStreaming,
|
|
1303
|
-
modelOptions,
|
|
1304
|
-
selectedModel,
|
|
1305
|
-
setSelectedModel,
|
|
1306
|
-
showModelSelector: modelOptions.length > 1,
|
|
1307
|
-
};
|
|
1308
|
-
}
|