@drawnagency/primitives 0.1.88 → 0.1.90
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/{chunk-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
- package/dist/{chunk-OEL57NYV.js → chunk-AGWE2LJG.js} +26 -2
- package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
- package/dist/{chunk-PTVY3Q7P.js → chunk-KBQNJZUX.js} +1 -1
- package/dist/{chunk-XPUBUR64.js → chunk-KMQZYORS.js} +28 -2
- package/dist/{chunk-WA3HFN54.js → chunk-MK7YB2A4.js} +1 -1
- package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
- package/dist/{chunk-XDG2K6UT.js → chunk-SIYQ37QY.js} +13 -5
- package/dist/{chunk-NEDOJHHR.js → chunk-XKRQMTE4.js} +11 -2
- package/dist/{chunk-SK6GGTTO.js → chunk-XUIJHYKI.js} +3 -3
- package/dist/{chunk-H2FENAQH.js → chunk-ZLG7S54V.js} +242 -83
- package/dist/components/chat/ChatPanel.d.ts +20 -3
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts +7 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/dist/components/chat/chat-history.d.ts +24 -0
- package/dist/components/chat/chat-history.d.ts.map +1 -0
- package/dist/components/editor/AudienceIndicator.d.ts +6 -0
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
- package/dist/components/editor/DragHandle.d.ts +3 -1
- package/dist/components/editor/DragHandle.d.ts.map +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +7 -0
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +100 -78
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Container/Container.d.ts +3 -2
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
- package/dist/components/sections/Container/index.d.ts +8 -2
- package/dist/components/sections/Container/index.d.ts.map +1 -1
- package/dist/components/sections/SectionLayout.d.ts +3 -1
- package/dist/components/sections/SectionLayout.d.ts.map +1 -1
- package/dist/components/sections/ViewRenderer.d.ts +3 -1
- package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +8 -2
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/core-sections.d.ts +8 -2
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- package/dist/components/shared/Navigation.d.ts +6 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Tooltip.d.ts +8 -1
- package/dist/components/shared/Tooltip.d.ts.map +1 -1
- package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
- package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
- package/dist/components/shell/EditorContext.d.ts +3 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +5 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/lib/authoring-defaults.d.ts +17 -0
- package/dist/lib/authoring-defaults.d.ts.map +1 -0
- package/dist/lib/chatbot.d.ts +10 -0
- package/dist/lib/chatbot.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +6 -0
- package/dist/lib/container-grid.d.ts.map +1 -1
- package/dist/lib/contrast.d.ts +5 -0
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +11 -3
- package/dist/lib/migrate-sections-transform.js +1 -1
- package/dist/media/compose-preview.d.ts +23 -0
- package/dist/media/compose-preview.d.ts.map +1 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +4 -2
- package/dist/media/preview-inputs.d.ts +31 -0
- package/dist/media/preview-inputs.d.ts.map +1 -0
- package/dist/schemas/index.js +4 -2
- package/dist/schemas/media.d.ts +2 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +18 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatPanel.tsx +437 -65
- package/src/components/chat/ChatWidget.tsx +90 -32
- package/src/components/chat/chat-history.ts +105 -0
- package/src/components/editor/AudienceIndicator.tsx +50 -37
- package/src/components/editor/ChildBlockWrapper.tsx +8 -7
- package/src/components/editor/ColSpanControl.tsx +14 -11
- package/src/components/editor/DragHandle.tsx +5 -2
- package/src/components/editor/PagesModal.tsx +10 -10
- package/src/components/editor/SectionWrapper.tsx +32 -21
- package/src/components/editor/StatusIndicator.tsx +37 -24
- package/src/components/sections/Container/Container.tsx +185 -43
- package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
- package/src/components/sections/Container/index.tsx +45 -20
- package/src/components/sections/SectionLayout.tsx +13 -3
- package/src/components/sections/ViewRenderer.tsx +11 -2
- package/src/components/shared/Navigation.tsx +18 -5
- package/src/components/shared/Tooltip.tsx +18 -2
- package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
- package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +130 -13
- package/src/components/shell/MediaLibraryContext.tsx +1 -1
- package/src/components/shell/SiteSettingsChatbot.tsx +49 -1
- package/src/hooks/useBuildStatus.ts +35 -4
- package/src/hooks/useMediaPipeline.ts +7 -3
- package/src/lib/authoring-defaults.ts +42 -0
- package/src/lib/chatbot.ts +30 -0
- package/src/lib/container-grid.ts +14 -0
- package/src/lib/contrast.ts +12 -0
- package/src/lib/index.ts +2 -1
- package/src/lib/migrate-sections-transform.ts +5 -3
- package/src/media/compose-preview.ts +90 -0
- package/src/media/index.ts +1 -0
- package/src/media/preview-inputs.ts +77 -0
- package/src/schemas/media.ts +3 -0
- package/src/schemas/site-config.ts +27 -2
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
|
2
2
|
import { marked } from "marked";
|
|
3
|
+
import { ArrowDown, ChevronDown, ChevronRight, History, Pin, PinOff, SquarePen, Square, Trash2 } from "lucide-react";
|
|
3
4
|
import { cn } from "../../lib/cn";
|
|
4
5
|
import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
|
|
6
|
+
import { resolveThinkingPhrases } from "../../lib/chatbot";
|
|
7
|
+
import { loadHistory, upsertConversation, setConversationPinned, removeConversation, type StoredConversation } from "./chat-history";
|
|
5
8
|
import { Select } from "../shared/Select";
|
|
6
9
|
import { IconButton } from "../shared/IconButton";
|
|
7
10
|
import { RemoveIcon } from "../shared/icons";
|
|
8
11
|
|
|
9
12
|
export interface ChatMessage { role: "user" | "assistant"; content: string }
|
|
10
13
|
|
|
14
|
+
/** How the conversation is framed on screen:
|
|
15
|
+
* - "floating": the compact anchored panel (/edit FAB + settings test chat)
|
|
16
|
+
* - "docked": full-height right sidebar (viewer, >=768px)
|
|
17
|
+
* - "sheet": bottom sheet (viewer, phones) */
|
|
18
|
+
export type ChatPanelMode = "floating" | "docked" | "sheet";
|
|
19
|
+
|
|
11
20
|
const SESSION_KEY = "bp-chat-session";
|
|
21
|
+
const MESSAGES_KEY = "bp-chat-messages";
|
|
22
|
+
const CONVO_ID_KEY = "bp-chat-convo-id";
|
|
23
|
+
const STORED_MESSAGE_CAP = 40;
|
|
24
|
+
/** Distance (px) from the bottom of the transcript within which auto-scroll
|
|
25
|
+
* stays engaged. Generous on purpose: a single wheel tick travels ~100px and
|
|
26
|
+
* must not silently disengage the pin. */
|
|
27
|
+
const PIN_THRESHOLD = 160;
|
|
12
28
|
|
|
13
29
|
function sessionId(): string {
|
|
14
30
|
let id = window.localStorage.getItem(SESSION_KEY);
|
|
@@ -19,12 +35,31 @@ function sessionId(): string {
|
|
|
19
35
|
return id;
|
|
20
36
|
}
|
|
21
37
|
|
|
38
|
+
/** Conversation persistence (viewer modes only): internal links open in the
|
|
39
|
+
* same tab, so the transcript must survive page navigation. sessionStorage —
|
|
40
|
+
* per-tab, cleared when the visit ends. */
|
|
41
|
+
function loadStoredMessages(): ChatMessage[] {
|
|
42
|
+
try {
|
|
43
|
+
const parsed: unknown = JSON.parse(window.sessionStorage.getItem(MESSAGES_KEY) ?? "[]");
|
|
44
|
+
if (!Array.isArray(parsed)) return [];
|
|
45
|
+
return parsed
|
|
46
|
+
.filter((m): m is ChatMessage =>
|
|
47
|
+
!!m && typeof m === "object" &&
|
|
48
|
+
((m as ChatMessage).role === "user" || (m as ChatMessage).role === "assistant") &&
|
|
49
|
+
typeof (m as ChatMessage).content === "string")
|
|
50
|
+
.slice(-STORED_MESSAGE_CAP);
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
const ERROR_COPY: Record<string, string> = {
|
|
23
57
|
rate_limited: "You've hit the message limit — try again in a minute.",
|
|
24
58
|
disabled: "Chat isn't available right now.",
|
|
25
59
|
not_authorized: "You don't have access to chat on this site.",
|
|
26
60
|
upstream_error: "Something went wrong — please try again.",
|
|
27
61
|
};
|
|
62
|
+
const RETRYABLE = new Set(["rate_limited", "upstream_error"]);
|
|
28
63
|
|
|
29
64
|
/** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
|
|
30
65
|
* having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
|
|
@@ -34,7 +69,7 @@ export function assistantHtml(md: string): string {
|
|
|
34
69
|
}
|
|
35
70
|
|
|
36
71
|
// Tailwind preflight makes h1–h6 inherit size/weight, so headings only need
|
|
37
|
-
// weight + spacing to clamp to the
|
|
72
|
+
// weight + spacing to clamp to the message's text scale.
|
|
38
73
|
const MD_STYLES =
|
|
39
74
|
"[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
|
|
40
75
|
"[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
|
|
@@ -49,12 +84,14 @@ const MD_STYLES =
|
|
|
49
84
|
export async function streamChat(
|
|
50
85
|
body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown; simulateAudience?: string },
|
|
51
86
|
onText: (text: string) => void,
|
|
87
|
+
signal?: AbortSignal,
|
|
52
88
|
): Promise<{ ok: true; truncated: boolean } | { ok: false; error: string; retryAfterMs?: number }> {
|
|
53
89
|
try {
|
|
54
90
|
const res = await fetch("/api/chat", {
|
|
55
91
|
method: "POST",
|
|
56
92
|
headers: { "Content-Type": "application/json" },
|
|
57
93
|
body: JSON.stringify(body),
|
|
94
|
+
signal,
|
|
58
95
|
});
|
|
59
96
|
if (!res.ok || !res.body) {
|
|
60
97
|
const err = await res.json().catch(() => ({ error: "upstream_error" }));
|
|
@@ -86,32 +123,72 @@ export async function streamChat(
|
|
|
86
123
|
}
|
|
87
124
|
return errored ? { ok: false, error: errored } : { ok: true, truncated };
|
|
88
125
|
} catch {
|
|
126
|
+
// A user-initiated stop is a clean finish, not a failure — whatever text
|
|
127
|
+
// already streamed stays in the transcript.
|
|
128
|
+
if (signal?.aborted) return { ok: true, truncated: false };
|
|
89
129
|
// Network failure or a mid-stream reader rejection (upstream abort) — never
|
|
90
130
|
// let this propagate out of send(), or `busy` sticks true forever.
|
|
91
131
|
return { ok: false, error: "upstream_error" };
|
|
92
132
|
}
|
|
93
133
|
}
|
|
94
134
|
|
|
95
|
-
export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer" }: {
|
|
135
|
+
export default function ChatPanel({ open, onClose, siteName, botName, thinkingPhrases, tasks, isEditor, draftChatbot, simulateAudience, variant = "viewer", mode = "floating", focusComposer, audience }: {
|
|
96
136
|
open: boolean;
|
|
97
137
|
onClose: () => void;
|
|
98
138
|
siteName: string;
|
|
139
|
+
/** Resolved assistant display name (per-audience, server-side). */
|
|
140
|
+
botName: string;
|
|
141
|
+
/** Raw configured phrases; "…" appending + fallback happen here. */
|
|
142
|
+
thinkingPhrases?: string[];
|
|
99
143
|
tasks: { id: string; label: string }[];
|
|
100
144
|
isEditor?: boolean;
|
|
101
145
|
/** Set by the configurator's test panel and the /edit chat FAB. */
|
|
102
146
|
draftChatbot?: unknown;
|
|
103
147
|
/** Configurator test panel only: preview this audience's prompt overlay. */
|
|
104
148
|
simulateAudience?: string;
|
|
105
|
-
/**
|
|
149
|
+
/** Floating mode only — anchors the panel above its launcher: viewer corner
|
|
150
|
+
* FAB vs /edit FAB column. */
|
|
106
151
|
variant?: "viewer" | "editor";
|
|
152
|
+
mode?: ChatPanelMode;
|
|
153
|
+
/** Focus the composer when opened (explicit user open on desktop). Never
|
|
154
|
+
* used for the sheet — autofocusing there pops the keyboard over content. */
|
|
155
|
+
focusComposer?: boolean;
|
|
156
|
+
/** Viewer's audience (null for editor sessions) — tags history entries so
|
|
157
|
+
* the list never shows another audience's transcripts. */
|
|
158
|
+
audience?: string | null;
|
|
107
159
|
}) {
|
|
108
|
-
const
|
|
160
|
+
const persistent = mode !== "floating";
|
|
161
|
+
const [messages, setMessages] = useState<ChatMessage[]>(() => (persistent ? loadStoredMessages() : []));
|
|
109
162
|
const [taskId, setTaskId] = useState<string>("");
|
|
110
163
|
const [input, setInput] = useState("");
|
|
111
164
|
const [busy, setBusy] = useState(false);
|
|
112
165
|
const [notice, setNotice] = useState<string | null>(null);
|
|
166
|
+
const [canRetry, setCanRetry] = useState(false);
|
|
167
|
+
const [thinking, setThinking] = useState("");
|
|
168
|
+
const [jumpVisible, setJumpVisible] = useState(false);
|
|
169
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
170
|
+
const [history, setHistory] = useState<StoredConversation[]>([]);
|
|
113
171
|
const streamingRef = useRef("");
|
|
172
|
+
const convoIdRef = useRef<string | null>(null);
|
|
173
|
+
const historyAudience = audience ?? "editor";
|
|
174
|
+
|
|
175
|
+
// Stable per-conversation id, shared with sessionStorage so a same-tab
|
|
176
|
+
// navigation keeps appending to the same history entry.
|
|
177
|
+
function ensureConvoId(): string {
|
|
178
|
+
if (!convoIdRef.current) {
|
|
179
|
+
try { convoIdRef.current = window.sessionStorage.getItem(CONVO_ID_KEY); } catch {}
|
|
180
|
+
if (!convoIdRef.current) {
|
|
181
|
+
convoIdRef.current = crypto.randomUUID();
|
|
182
|
+
try { window.sessionStorage.setItem(CONVO_ID_KEY, convoIdRef.current); } catch {}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return convoIdRef.current;
|
|
186
|
+
}
|
|
187
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
114
188
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
|
189
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
190
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
191
|
+
const pinnedRef = useRef(true);
|
|
115
192
|
|
|
116
193
|
// Autosize the composer: height tracks content up to the className's
|
|
117
194
|
// 4-line max-h cap, then it scrolls internally. Keyed on `input` so the
|
|
@@ -148,6 +225,84 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
148
225
|
return () => { alive = false; };
|
|
149
226
|
}, []);
|
|
150
227
|
|
|
228
|
+
// Persist the transcript across same-tab navigations (see loadStoredMessages).
|
|
229
|
+
// The mid-stream assistant stub is empty — don't store it. The history
|
|
230
|
+
// archive updates only between responses (`busy` in deps: the flip to false
|
|
231
|
+
// after a stream lands the final transcript) — not on every token.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!persistent) return;
|
|
234
|
+
const stored = messages.filter((m) => m.content !== "").slice(-STORED_MESSAGE_CAP);
|
|
235
|
+
try {
|
|
236
|
+
window.sessionStorage.setItem(MESSAGES_KEY, JSON.stringify(stored));
|
|
237
|
+
} catch { /* storage unavailable — transcript just won't survive navigation */ }
|
|
238
|
+
if (!busy && stored.length > 0) {
|
|
239
|
+
upsertConversation({ id: ensureConvoId(), audience: historyAudience, messages: stored });
|
|
240
|
+
}
|
|
241
|
+
}, [messages, busy, persistent, historyAudience]);
|
|
242
|
+
|
|
243
|
+
// Stick-to-bottom: keep the newest tokens in view ONLY while the reader is
|
|
244
|
+
// already near the bottom; the moment they scroll up to read, stop moving
|
|
245
|
+
// them. They re-engage by scrolling back down or via the jump pill.
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const el = listRef.current;
|
|
248
|
+
if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
|
|
249
|
+
}, [messages, thinking]);
|
|
250
|
+
|
|
251
|
+
function onListScroll() {
|
|
252
|
+
const el = listRef.current;
|
|
253
|
+
if (!el) return;
|
|
254
|
+
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
255
|
+
const pinned = distance < PIN_THRESHOLD;
|
|
256
|
+
pinnedRef.current = pinned;
|
|
257
|
+
setJumpVisible(!pinned);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function jumpToLatest() {
|
|
261
|
+
const el = listRef.current;
|
|
262
|
+
if (!el) return;
|
|
263
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
264
|
+
el.scrollTo({ top: el.scrollHeight, behavior: reduced ? "auto" : "smooth" });
|
|
265
|
+
pinnedRef.current = true;
|
|
266
|
+
setJumpVisible(false);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Sheet mode on iOS: the keyboard shrinks visualViewport while fixed
|
|
270
|
+
// elements stay put behind it. Track the keyboard as --kb-offset (raises
|
|
271
|
+
// the sheet) and the visual height as --vvh (caps the sheet's height).
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (mode !== "sheet" || !open) return;
|
|
274
|
+
const vv = window.visualViewport;
|
|
275
|
+
const el = panelRef.current;
|
|
276
|
+
if (!vv || !el) return;
|
|
277
|
+
const update = () => {
|
|
278
|
+
el.style.setProperty("--vvh", `${vv.height}px`);
|
|
279
|
+
el.style.setProperty("--kb-offset", `${Math.max(0, window.innerHeight - vv.height - vv.offsetTop)}px`);
|
|
280
|
+
};
|
|
281
|
+
update();
|
|
282
|
+
vv.addEventListener("resize", update);
|
|
283
|
+
vv.addEventListener("scroll", update);
|
|
284
|
+
return () => {
|
|
285
|
+
vv.removeEventListener("resize", update);
|
|
286
|
+
vv.removeEventListener("scroll", update);
|
|
287
|
+
};
|
|
288
|
+
}, [mode, open]);
|
|
289
|
+
|
|
290
|
+
// The open sheet is modal: the page behind it must not scroll.
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (mode !== "sheet" || !open) return;
|
|
293
|
+
const prev = document.body.style.overflow;
|
|
294
|
+
document.body.style.overflow = "hidden";
|
|
295
|
+
return () => { document.body.style.overflow = prev; };
|
|
296
|
+
}, [mode, open]);
|
|
297
|
+
|
|
298
|
+
// Explicit-open focus: composer on desktop, the panel itself on the sheet
|
|
299
|
+
// (keyboard-pop on open is hostile on phones).
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (!open) return;
|
|
302
|
+
if (mode === "sheet") panelRef.current?.focus();
|
|
303
|
+
else if (focusComposer) composerRef.current?.focus();
|
|
304
|
+
}, [open, mode, focusComposer]);
|
|
305
|
+
|
|
151
306
|
function onMessagesClick(e: MouseEvent<HTMLDivElement>) {
|
|
152
307
|
const link = (e.target as HTMLElement).closest("a");
|
|
153
308
|
if (!link || !link.getAttribute("href")) return;
|
|
@@ -157,7 +312,7 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
157
312
|
// #anchor but improvises the path around it (a bare "#anchor", or a path
|
|
158
313
|
// copied from elsewhere in the prompt). If the cited anchor exists in the
|
|
159
314
|
// CURRENT document — viewer page or /edit editor, both render heading
|
|
160
|
-
// ids — scroll to it in place
|
|
315
|
+
// ids — scroll to it in place.
|
|
161
316
|
let anchorId = "";
|
|
162
317
|
if (url.hash.length > 1 && url.origin === window.location.origin) {
|
|
163
318
|
try {
|
|
@@ -168,25 +323,38 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
168
323
|
}
|
|
169
324
|
const target = anchorId ? document.getElementById(anchorId) : null;
|
|
170
325
|
if (target) {
|
|
326
|
+
// Phone convention: the sheet covers the page, so following a citation
|
|
327
|
+
// collapses it to reveal the section. The docked sidebar stays open.
|
|
328
|
+
if (mode === "sheet") onClose();
|
|
171
329
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
172
330
|
window.history.replaceState(null, "", url.hash);
|
|
173
331
|
return;
|
|
174
332
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
333
|
+
if (url.origin === window.location.origin) {
|
|
334
|
+
if (persistent) {
|
|
335
|
+
// Internal cross-page citation, viewer chat: navigate in place — the
|
|
336
|
+
// sessionStorage transcript (and docked-open state) survive the load.
|
|
337
|
+
window.location.assign(url.href);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// Editor surfaces (floating mode): same-tab navigation would destroy
|
|
341
|
+
// unsaved editor state — keep internal links in a new tab there.
|
|
342
|
+
window.open(url.href, "_blank", "noopener");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
178
345
|
window.open(url.href, "_blank", "noopener");
|
|
179
346
|
}
|
|
180
347
|
|
|
181
|
-
async function
|
|
182
|
-
const text = input.trim();
|
|
183
|
-
if (!text || busy) return;
|
|
348
|
+
async function run(history: ChatMessage[]) {
|
|
184
349
|
setNotice(null);
|
|
185
|
-
|
|
350
|
+
setCanRetry(false);
|
|
186
351
|
setBusy(true);
|
|
187
|
-
const
|
|
352
|
+
const phrases = resolveThinkingPhrases(thinkingPhrases);
|
|
353
|
+
setThinking(phrases[Math.floor(Math.random() * phrases.length)]);
|
|
188
354
|
setMessages([...history, { role: "assistant", content: "" }]);
|
|
189
355
|
streamingRef.current = "";
|
|
356
|
+
const controller = new AbortController();
|
|
357
|
+
abortRef.current = controller;
|
|
190
358
|
const result = await streamChat(
|
|
191
359
|
{ messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}), ...(simulateAudience ? { simulateAudience } : {}) },
|
|
192
360
|
(delta) => {
|
|
@@ -194,72 +362,276 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
194
362
|
const streamed = streamingRef.current;
|
|
195
363
|
setMessages([...history, { role: "assistant", content: streamed }]);
|
|
196
364
|
},
|
|
365
|
+
controller.signal,
|
|
197
366
|
);
|
|
367
|
+
abortRef.current = null;
|
|
198
368
|
setBusy(false);
|
|
199
369
|
if (!result.ok) {
|
|
200
370
|
setMessages(history); // drop the empty assistant stub
|
|
201
371
|
setNotice(ERROR_COPY[result.error] ?? ERROR_COPY.upstream_error);
|
|
202
|
-
|
|
203
|
-
|
|
372
|
+
setCanRetry(RETRYABLE.has(result.error));
|
|
373
|
+
} else {
|
|
374
|
+
if (!streamingRef.current) setMessages(history); // stopped before the first token
|
|
375
|
+
if (result.truncated) setNotice("Some brand content didn't fit — answers may be incomplete.");
|
|
204
376
|
}
|
|
205
377
|
}
|
|
206
378
|
|
|
379
|
+
async function send(textOverride?: string) {
|
|
380
|
+
const text = (textOverride ?? input).trim();
|
|
381
|
+
if (!text || busy) return;
|
|
382
|
+
if (textOverride === undefined) setInput("");
|
|
383
|
+
await run([...messages, { role: "user", content: text }]);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function retry() {
|
|
387
|
+
if (busy || messages.length === 0 || messages[messages.length - 1].role !== "user") return;
|
|
388
|
+
void run(messages);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function stop() {
|
|
392
|
+
abortRef.current?.abort();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Starts fresh; the previous conversation stays in the history archive.
|
|
396
|
+
function clearConversation() {
|
|
397
|
+
if (busy) stop();
|
|
398
|
+
setMessages([]);
|
|
399
|
+
setNotice(null);
|
|
400
|
+
setCanRetry(false);
|
|
401
|
+
setShowHistory(false);
|
|
402
|
+
convoIdRef.current = null;
|
|
403
|
+
try {
|
|
404
|
+
window.sessionStorage.removeItem(MESSAGES_KEY);
|
|
405
|
+
window.sessionStorage.removeItem(CONVO_ID_KEY);
|
|
406
|
+
} catch {}
|
|
407
|
+
composerRef.current?.focus();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function openHistory() {
|
|
411
|
+
setHistory(loadHistory(historyAudience));
|
|
412
|
+
setShowHistory(true);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function restoreConversation(c: StoredConversation) {
|
|
416
|
+
if (busy) stop();
|
|
417
|
+
convoIdRef.current = c.id;
|
|
418
|
+
try { window.sessionStorage.setItem(CONVO_ID_KEY, c.id); } catch {}
|
|
419
|
+
setMessages(c.messages);
|
|
420
|
+
setNotice(null);
|
|
421
|
+
setCanRetry(false);
|
|
422
|
+
setShowHistory(false);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function togglePinned(c: StoredConversation) {
|
|
426
|
+
setConversationPinned(c.id, !c.pinned);
|
|
427
|
+
setHistory(loadHistory(historyAudience));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function deleteFromHistory(c: StoredConversation) {
|
|
431
|
+
removeConversation(c.id);
|
|
432
|
+
// Deleting the ACTIVE conversation also clears the live transcript —
|
|
433
|
+
// otherwise the next message would silently resurrect the entry.
|
|
434
|
+
if (c.id === convoIdRef.current) {
|
|
435
|
+
setMessages([]);
|
|
436
|
+
convoIdRef.current = null;
|
|
437
|
+
try {
|
|
438
|
+
window.sessionStorage.removeItem(MESSAGES_KEY);
|
|
439
|
+
window.sessionStorage.removeItem(CONVO_ID_KEY);
|
|
440
|
+
} catch {}
|
|
441
|
+
}
|
|
442
|
+
setHistory(loadHistory(historyAudience));
|
|
443
|
+
}
|
|
444
|
+
|
|
207
445
|
if (!open) return null;
|
|
208
446
|
|
|
447
|
+
const lastIndex = messages.length - 1;
|
|
448
|
+
|
|
209
449
|
return (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
"fixed
|
|
213
|
-
variant === "editor" ? "bottom-4 fab-panel-left max-w-[calc(100vw-5.25rem)]" : "bottom-20 right-4",
|
|
214
|
-
)}>
|
|
215
|
-
|
|
216
|
-
<div className="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">
|
|
217
|
-
<span className="text-sm font-medium text-base-contrast">Portal Assistant</span>
|
|
218
|
-
<IconButton icon={<RemoveIcon size={18} />} label="Close chat" onClick={onClose} className="rounded-inner hover:bg-base-accent" />
|
|
219
|
-
</div>
|
|
220
|
-
{tasks.length > 0 && (
|
|
221
|
-
<div className="border-b border-base-200 px-3 py-2">
|
|
222
|
-
<Select
|
|
223
|
-
label="Task"
|
|
224
|
-
value={taskId}
|
|
225
|
-
onChange={setTaskId}
|
|
226
|
-
options={[{ value: "", label: "General" }, ...tasks.map((t) => ({ value: t.id, label: t.label }))]}
|
|
227
|
-
selectClassName="py-1"
|
|
228
|
-
/>
|
|
229
|
-
</div>
|
|
450
|
+
<>
|
|
451
|
+
{mode === "sheet" && (
|
|
452
|
+
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose} aria-hidden="true" />
|
|
230
453
|
)}
|
|
231
|
-
<div
|
|
232
|
-
{
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
</div>
|
|
454
|
+
<div
|
|
455
|
+
ref={panelRef}
|
|
456
|
+
role={mode === "docked" ? "complementary" : "dialog"}
|
|
457
|
+
aria-modal={mode === "sheet" ? true : undefined}
|
|
458
|
+
aria-label={botName}
|
|
459
|
+
tabIndex={-1}
|
|
460
|
+
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
461
|
+
className={cn(
|
|
462
|
+
"flex flex-col bg-base focus:outline-none",
|
|
463
|
+
mode === "floating" && cn(
|
|
464
|
+
"fixed z-40 h-[28rem] w-80 rounded-outer border border-base-200 shadow-xl",
|
|
465
|
+
variant === "editor" ? "bottom-4 fab-panel-left max-w-[calc(100vw-5.25rem)]" : "bottom-20 right-4",
|
|
244
466
|
),
|
|
467
|
+
mode === "docked" && "fixed right-0 bottom-0 top-16 xl:top-0 z-40 w-[var(--chat-w)] border-l border-base-200",
|
|
468
|
+
mode === "sheet" && "fixed inset-x-0 bottom-0 z-50 h-[85dvh] rounded-t-outer border-t border-base-200 shadow-xl",
|
|
245
469
|
)}
|
|
246
|
-
{
|
|
470
|
+
style={mode === "sheet" ? { bottom: "var(--kb-offset, 0px)", maxHeight: "calc(var(--vvh, 100dvh) - 2.5rem)" } : undefined}
|
|
471
|
+
>
|
|
472
|
+
<div className={cn(
|
|
473
|
+
"flex shrink-0 items-center justify-between gap-2 border-b border-base-200 px-3",
|
|
474
|
+
mode === "docked" ? "h-16" : "py-2",
|
|
475
|
+
)}>
|
|
476
|
+
<span className="text-sm font-medium text-base-contrast">{botName}</span>
|
|
477
|
+
<div className="flex items-center gap-1">
|
|
478
|
+
{persistent && (
|
|
479
|
+
<IconButton icon={<History size={16} />} label="Chat history"
|
|
480
|
+
onClick={() => (showHistory ? setShowHistory(false) : openHistory())}
|
|
481
|
+
className="rounded-inner hover:bg-base-accent" />
|
|
482
|
+
)}
|
|
483
|
+
{messages.length > 0 && (
|
|
484
|
+
<IconButton icon={<SquarePen size={16} />} label="New conversation" onClick={clearConversation} className="rounded-inner hover:bg-base-accent" />
|
|
485
|
+
)}
|
|
486
|
+
<IconButton
|
|
487
|
+
icon={mode === "docked" ? <ChevronRight size={18} /> : mode === "sheet" ? <ChevronDown size={18} /> : <RemoveIcon size={18} />}
|
|
488
|
+
label={mode === "docked" ? "Hide chat" : "Close chat"}
|
|
489
|
+
onClick={onClose}
|
|
490
|
+
className="rounded-inner hover:bg-base-accent"
|
|
491
|
+
/>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
{tasks.length > 0 && (
|
|
495
|
+
<div className="shrink-0 border-b border-base-200 px-3 py-2">
|
|
496
|
+
<Select
|
|
497
|
+
label="Task"
|
|
498
|
+
value={taskId}
|
|
499
|
+
onChange={setTaskId}
|
|
500
|
+
options={[{ value: "", label: "General" }, ...tasks.map((t) => ({ value: t.id, label: t.label }))]}
|
|
501
|
+
selectClassName="py-1"
|
|
502
|
+
/>
|
|
503
|
+
</div>
|
|
504
|
+
)}
|
|
505
|
+
<div className="relative min-h-0 flex-1">
|
|
506
|
+
<div
|
|
507
|
+
ref={listRef}
|
|
508
|
+
role="log"
|
|
509
|
+
aria-label="Conversation"
|
|
510
|
+
onScroll={onListScroll}
|
|
511
|
+
onClick={onMessagesClick}
|
|
512
|
+
className="h-full space-y-3 overflow-y-auto p-3"
|
|
513
|
+
>
|
|
514
|
+
{messages.length === 0 && (
|
|
515
|
+
<div className="flex h-full flex-col justify-end gap-2 pb-1">
|
|
516
|
+
<p className="text-sm text-base-contrast-light">
|
|
517
|
+
Ask about {siteName} — colors, voice, logo usage, anything in the brand guide.
|
|
518
|
+
</p>
|
|
519
|
+
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
520
|
+
{tasks.slice(0, 4).map((t) => (
|
|
521
|
+
<button key={t.id} type="button"
|
|
522
|
+
onClick={() => { setTaskId(t.id); composerRef.current?.focus(); }}
|
|
523
|
+
className="cursor-pointer rounded-full border border-base-200 bg-base-accent px-2.5 py-1 text-xs text-base-contrast hover:border-primary">
|
|
524
|
+
{t.label}
|
|
525
|
+
</button>
|
|
526
|
+
))}
|
|
527
|
+
{tasks.length === 0 && ["What are our primary brand colors?", "How should the logo be used?"].map((s) => (
|
|
528
|
+
<button key={s} type="button" onClick={() => void send(s)}
|
|
529
|
+
className="cursor-pointer rounded-full border border-base-200 bg-base-accent px-2.5 py-1 text-xs text-base-contrast hover:border-primary">
|
|
530
|
+
{s}
|
|
531
|
+
</button>
|
|
532
|
+
))}
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
)}
|
|
536
|
+
{messages.map((m, i) =>
|
|
537
|
+
m.role === "user" ? (
|
|
538
|
+
<div key={i} className="ml-auto max-w-[85%] whitespace-pre-wrap rounded-outer bg-primary px-3 py-2 text-sm text-primary-contrast">
|
|
539
|
+
{m.content}
|
|
540
|
+
</div>
|
|
541
|
+
) : m.content === "" ? (
|
|
542
|
+
busy && i === lastIndex ? (
|
|
543
|
+
<div key={i} className="text-sm text-base-contrast-light motion-safe:animate-pulse">{thinking}</div>
|
|
544
|
+
) : null
|
|
545
|
+
) : mdReady ? (
|
|
546
|
+
<div
|
|
547
|
+
key={i}
|
|
548
|
+
className={cn("text-sm leading-relaxed text-base-contrast", MD_STYLES)}
|
|
549
|
+
dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
|
|
550
|
+
/>
|
|
551
|
+
) : (
|
|
552
|
+
<div key={i} className="whitespace-pre-wrap text-sm leading-relaxed text-base-contrast">{m.content}</div>
|
|
553
|
+
),
|
|
554
|
+
)}
|
|
555
|
+
{notice && (
|
|
556
|
+
<p className="text-xs text-base-contrast-light">
|
|
557
|
+
{notice}
|
|
558
|
+
{canRetry && !busy && (
|
|
559
|
+
<button type="button" onClick={retry} className="ml-2 cursor-pointer text-primary underline underline-offset-2">
|
|
560
|
+
Try again
|
|
561
|
+
</button>
|
|
562
|
+
)}
|
|
563
|
+
</p>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
{showHistory && persistent && (
|
|
567
|
+
<div className="absolute inset-0 z-20 overflow-y-auto bg-base p-2" role="region" aria-label="Saved conversations">
|
|
568
|
+
<p className="px-1 pb-2 text-xs font-medium text-base-contrast-light">
|
|
569
|
+
Chat history — pinned conversations are kept forever; others expire after 30 days.
|
|
570
|
+
</p>
|
|
571
|
+
{history.length === 0 ? (
|
|
572
|
+
<p className="px-1 text-xs text-base-contrast-light">No saved conversations yet.</p>
|
|
573
|
+
) : (
|
|
574
|
+
<ul className="space-y-1">
|
|
575
|
+
{history.map((c) => (
|
|
576
|
+
<li key={c.id} className="flex items-center gap-1 rounded-inner border border-base-200 px-2 py-1.5 hover:bg-base-accent">
|
|
577
|
+
<button type="button" onClick={() => restoreConversation(c)} className="min-w-0 flex-1 cursor-pointer text-left">
|
|
578
|
+
<span className="block truncate text-sm text-base-contrast">{c.title}</span>
|
|
579
|
+
<span className="block text-[10px] text-base-contrast-light">
|
|
580
|
+
{c.pinned ? "Pinned · " : ""}
|
|
581
|
+
{new Date(c.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
|
582
|
+
</span>
|
|
583
|
+
</button>
|
|
584
|
+
<IconButton icon={c.pinned ? <PinOff size={14} /> : <Pin size={14} />}
|
|
585
|
+
label={c.pinned ? `Unpin "${c.title}"` : `Pin "${c.title}"`}
|
|
586
|
+
onClick={() => togglePinned(c)} className="rounded-inner hover:bg-base-200" />
|
|
587
|
+
<IconButton icon={<Trash2 size={14} />} label={`Delete "${c.title}"`}
|
|
588
|
+
onClick={() => deleteFromHistory(c)} className="rounded-inner hover:bg-base-200" />
|
|
589
|
+
</li>
|
|
590
|
+
))}
|
|
591
|
+
</ul>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
)}
|
|
595
|
+
{jumpVisible && (
|
|
596
|
+
<button
|
|
597
|
+
type="button"
|
|
598
|
+
onClick={jumpToLatest}
|
|
599
|
+
className="absolute bottom-2 left-1/2 z-10 flex -translate-x-1/2 cursor-pointer items-center gap-1 rounded-full border border-base-200 bg-base px-3 py-1 text-xs text-base-contrast shadow-md hover:bg-base-accent"
|
|
600
|
+
>
|
|
601
|
+
<ArrowDown size={12} aria-hidden="true" /> Latest
|
|
602
|
+
</button>
|
|
603
|
+
)}
|
|
604
|
+
</div>
|
|
605
|
+
<p role="status" className="sr-only">{busy ? thinking : ""}</p>
|
|
606
|
+
<form
|
|
607
|
+
className={cn(
|
|
608
|
+
"flex shrink-0 items-end gap-2 border-t border-base-200 p-2",
|
|
609
|
+
mode === "sheet" && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
610
|
+
)}
|
|
611
|
+
onSubmit={(e) => { e.preventDefault(); void send(); }}
|
|
612
|
+
>
|
|
613
|
+
<textarea
|
|
614
|
+
ref={composerRef}
|
|
615
|
+
rows={1}
|
|
616
|
+
value={input}
|
|
617
|
+
onChange={(e) => setInput(e.target.value)}
|
|
618
|
+
onKeyDown={onComposerKeyDown}
|
|
619
|
+
placeholder={`Ask about ${siteName}…`}
|
|
620
|
+
maxLength={4000}
|
|
621
|
+
className="max-h-[5.625rem] min-w-0 flex-1 resize-none overflow-y-auto rounded-inner border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:bg-[color-mix(in_srgb,var(--color-base),var(--color-base-contrast)_5%)]"
|
|
622
|
+
/>
|
|
623
|
+
{busy ? (
|
|
624
|
+
<button type="button" aria-label="Stop" onClick={stop}
|
|
625
|
+
className="flex cursor-pointer items-center rounded-inner bg-primary px-3 py-1.5 text-sm text-primary-contrast">
|
|
626
|
+
<Square size={12} fill="currentColor" aria-hidden="true" />
|
|
627
|
+
</button>
|
|
628
|
+
) : (
|
|
629
|
+
<button type="submit" aria-label="Send"
|
|
630
|
+
className="rounded-inner bg-primary px-3 py-1 text-sm text-primary-contrast disabled:opacity-50">→</button>
|
|
631
|
+
)}
|
|
632
|
+
</form>
|
|
633
|
+
{isEditor && <p className="shrink-0 border-t border-base-200 px-3 py-1 text-[10px] text-base-contrast-light">Editor view — answers ground on published content.</p>}
|
|
247
634
|
</div>
|
|
248
|
-
|
|
249
|
-
<textarea
|
|
250
|
-
ref={composerRef}
|
|
251
|
-
rows={1}
|
|
252
|
-
value={input}
|
|
253
|
-
onChange={(e) => setInput(e.target.value)}
|
|
254
|
-
onKeyDown={onComposerKeyDown}
|
|
255
|
-
placeholder={`Ask about ${siteName}…`}
|
|
256
|
-
maxLength={4000}
|
|
257
|
-
className="max-h-[5.625rem] min-w-0 flex-1 resize-none overflow-y-auto rounded-inner border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:bg-[color-mix(in_srgb,var(--color-base),var(--color-base-contrast)_5%)]"
|
|
258
|
-
/>
|
|
259
|
-
<button type="submit" aria-label="Send" disabled={busy}
|
|
260
|
-
className="rounded-inner bg-primary px-3 py-1 text-sm text-primary-contrast disabled:opacity-50">→</button>
|
|
261
|
-
</form>
|
|
262
|
-
{isEditor && <p className="border-t border-base-200 px-3 py-1 text-[10px] text-base-contrast-light">Editor view — answers ground on published content.</p>}
|
|
263
|
-
</div>
|
|
635
|
+
</>
|
|
264
636
|
);
|
|
265
637
|
}
|