@drawnagency/primitives 0.1.69 → 0.1.70
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/components/chat/ChatPanel.d.ts +4 -0
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/components/chat/ChatPanel.tsx +86 -8
- package/src/components/chat/ChatWidget.tsx +14 -7
- package/src/components/shell/BugReportFAB.tsx +1 -1
- package/src/components/shell/EditorShell.tsx +1 -1
|
@@ -2,6 +2,10 @@ export interface ChatMessage {
|
|
|
2
2
|
role: "user" | "assistant";
|
|
3
3
|
content: string;
|
|
4
4
|
}
|
|
5
|
+
/** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
|
|
6
|
+
* having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
|
|
7
|
+
* through, and model output is untrusted. Exported for tests. */
|
|
8
|
+
export declare function assistantHtml(md: string): string;
|
|
5
9
|
/** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
|
|
6
10
|
export declare function streamChat(body: {
|
|
7
11
|
messages: ChatMessage[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ChatPanel.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatPanel.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,WAAW;IAAG,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AA6B5E;;kEAEkE;AAClE,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEhD;AAcD,8EAA8E;AAC9E,wBAAsB,UAAU,CAC9B,IAAI,EAAE;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7F,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC7B,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjG;AAED,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE;IAChH,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,mFAAmF;IACnF,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B,kDA6HA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE,eAAe,
|
|
1
|
+
{"version":3,"file":"ChatWidget.d.ts","sourceRoot":"","sources":["../../../src/components/chat/ChatWidget.tsx"],"names":[],"mappings":"AAcA,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,OAAO,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;CAC/B;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAkB,EAAE,EAAE,eAAe,2CA2ClH"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drawnagency/primitives",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.70",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -106,6 +106,7 @@
|
|
|
106
106
|
"dompurify": "^3.2.6",
|
|
107
107
|
"jose": "^6.2.2",
|
|
108
108
|
"lucide-react": "^1.7.0",
|
|
109
|
+
"marked": "^18.0.5",
|
|
109
110
|
"tailwind-merge": "^3.5.0",
|
|
110
111
|
"zod": "^4.3.6"
|
|
111
112
|
},
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
|
2
|
+
import { marked } from "marked";
|
|
2
3
|
import { cn } from "../../lib/cn";
|
|
4
|
+
import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
|
|
3
5
|
|
|
4
6
|
export interface ChatMessage { role: "user" | "assistant"; content: string }
|
|
5
7
|
|
|
6
8
|
const SESSION_KEY = "bp-chat-session";
|
|
7
9
|
|
|
10
|
+
// Astro's trailingSlash: "ignore" means the viewer can be on "/brand/" while
|
|
11
|
+
// grounding links say "/brand#voice-tone" — the pathnames are equivalent
|
|
12
|
+
// but not literally equal. Per the HTML spec, same-document fragment
|
|
13
|
+
// navigation requires URL equality *excluding* the fragment, so a literal
|
|
14
|
+
// mismatch (e.g. "/brand/" vs "/brand") can't rely on native navigation —
|
|
15
|
+
// this helper only detects that "equivalent but not equal" case so we can
|
|
16
|
+
// handle it ourselves. "/" stays "/" rather than collapsing to "".
|
|
17
|
+
const normalizePathname = (p: string): string => p.replace(/\/+$/, "") || "/";
|
|
18
|
+
|
|
8
19
|
function sessionId(): string {
|
|
9
20
|
let id = window.localStorage.getItem(SESSION_KEY);
|
|
10
21
|
if (!id) {
|
|
@@ -21,6 +32,25 @@ const ERROR_COPY: Record<string, string> = {
|
|
|
21
32
|
upstream_error: "Something went wrong — please try again.",
|
|
22
33
|
};
|
|
23
34
|
|
|
35
|
+
/** Assistant markdown → sanitized HTML. Callers MUST gate on the sanitizer
|
|
36
|
+
* having loaded (`mdReady`) — before then sanitizeHtml passes raw HTML
|
|
37
|
+
* through, and model output is untrusted. Exported for tests. */
|
|
38
|
+
export function assistantHtml(md: string): string {
|
|
39
|
+
return sanitizeHtml(marked.parse(md, { async: false, gfm: true, breaks: true }) as string);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Tailwind preflight makes h1–h6 inherit size/weight, so headings only need
|
|
43
|
+
// weight + spacing to clamp to the bubble's text scale.
|
|
44
|
+
const MD_STYLES =
|
|
45
|
+
"[&_p:not(:first-child)]:mt-2 [&_ul]:mt-1 [&_ul]:list-disc [&_ul]:pl-4 " +
|
|
46
|
+
"[&_ol]:mt-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_li:not(:first-child)]:mt-1 " +
|
|
47
|
+
"[&_a]:underline [&_a]:underline-offset-2 " +
|
|
48
|
+
"[&_code]:rounded [&_code]:bg-base-200 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.8em] " +
|
|
49
|
+
"[&_pre]:mt-2 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-base-200 [&_pre]:p-2 " +
|
|
50
|
+
"[&_h1]:mt-2 [&_h1]:font-semibold [&_h2]:mt-2 [&_h2]:font-semibold " +
|
|
51
|
+
"[&_h3]:mt-2 [&_h3]:font-semibold [&_h4]:mt-2 [&_h4]:font-semibold " +
|
|
52
|
+
"[&_blockquote]:mt-2 [&_blockquote]:border-l-2 [&_blockquote]:border-base-200 [&_blockquote]:pl-2";
|
|
53
|
+
|
|
24
54
|
/** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
|
|
25
55
|
export async function streamChat(
|
|
26
56
|
body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown },
|
|
@@ -86,6 +116,46 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
86
116
|
const [notice, setNotice] = useState<string | null>(null);
|
|
87
117
|
const streamingRef = useRef("");
|
|
88
118
|
|
|
119
|
+
const [mdReady, setMdReady] = useState(false);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
let alive = true;
|
|
122
|
+
// Failure just leaves mdReady false forever (plain-text messages) — never
|
|
123
|
+
// let it surface as an unhandled rejection.
|
|
124
|
+
void ensureSanitizer().then(() => { if (alive) setMdReady(true); }).catch(() => {});
|
|
125
|
+
return () => { alive = false; };
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
function onMessagesClick(e: MouseEvent<HTMLDivElement>) {
|
|
129
|
+
const link = (e.target as HTMLElement).closest("a");
|
|
130
|
+
if (!link || !link.getAttribute("href")) return;
|
|
131
|
+
const url = new URL(link.href, window.location.href);
|
|
132
|
+
// Native navigation is only safe when there's a hash to scroll to — the
|
|
133
|
+
// grounding emits hashless same-pathname links (e.g. "/") for content
|
|
134
|
+
// before the first heading, and letting those navigate natively would
|
|
135
|
+
// full-reload the page, unmounting the panel and wiping chat history.
|
|
136
|
+
const sameOriginWithHash = variant !== "editor" && url.origin === window.location.origin && url.hash !== "";
|
|
137
|
+
if (sameOriginWithHash) {
|
|
138
|
+
if (url.pathname === window.location.pathname) {
|
|
139
|
+
return; // literally same document: native hash navigation, panel stays open
|
|
140
|
+
}
|
|
141
|
+
if (normalizePathname(url.pathname) === normalizePathname(window.location.pathname)) {
|
|
142
|
+
// Equivalent pathnames (trailing-slash only difference) but NOT
|
|
143
|
+
// literally equal — per spec, same-document fragment navigation
|
|
144
|
+
// requires URL equality excluding the fragment, so the browser would
|
|
145
|
+
// treat a literal mismatch as a full cross-document navigation,
|
|
146
|
+
// unmounting the panel and wiping chat history. Perform the fragment
|
|
147
|
+
// navigation ourselves on the current (already-correct) URL instead.
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
window.location.hash = url.hash;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Cross-page (or same-page but hashless, or any /edit click): new tab so
|
|
154
|
+
// chat history / unsaved editor state survive.
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
window.open(url.href, "_blank", "noopener");
|
|
157
|
+
}
|
|
158
|
+
|
|
89
159
|
async function send() {
|
|
90
160
|
const text = input.trim();
|
|
91
161
|
if (!text || busy) return;
|
|
@@ -132,13 +202,21 @@ export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, dr
|
|
|
132
202
|
)}
|
|
133
203
|
<button type="button" aria-label="Close chat" onClick={onClose} className="text-base-contrast-light hover:text-base-contrast">×</button>
|
|
134
204
|
</div>
|
|
135
|
-
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
|
136
|
-
{messages.map((m, i) =>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
205
|
+
<div className="flex-1 space-y-2 overflow-y-auto p-3" onClick={onMessagesClick}>
|
|
206
|
+
{messages.map((m, i) =>
|
|
207
|
+
m.role === "assistant" && mdReady && m.content ? (
|
|
208
|
+
<div
|
|
209
|
+
key={i}
|
|
210
|
+
className={cn("max-w-[85%] rounded-lg bg-base-accent px-3 py-2 text-sm text-base-contrast", MD_STYLES)}
|
|
211
|
+
dangerouslySetInnerHTML={{ __html: assistantHtml(m.content) }}
|
|
212
|
+
/>
|
|
213
|
+
) : (
|
|
214
|
+
<div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
|
|
215
|
+
m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
|
|
216
|
+
{m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
|
|
217
|
+
</div>
|
|
218
|
+
),
|
|
219
|
+
)}
|
|
142
220
|
{notice && <p className="text-xs text-base-contrast-light">{notice}</p>}
|
|
143
221
|
</div>
|
|
144
222
|
<form className="flex gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Two placements:
|
|
6
6
|
// - "viewer" (default): bottom-right corner of viewer pages.
|
|
7
|
-
// - "editor":
|
|
8
|
-
//
|
|
7
|
+
// - "editor": BOTTOM of the /edit route's FAB column (edit-mode toggle at
|
|
8
|
+
// bottom-16, bug report at bottom-28 above it), aligned to the content edge
|
|
9
9
|
// via fab-container-right like its neighbors.
|
|
10
10
|
import { lazy, Suspense, useState } from "react";
|
|
11
11
|
import { cn } from "../../lib/cn";
|
|
@@ -30,18 +30,25 @@ export default function ChatWidget({ siteName, tasks, isEditor, draftChatbot, va
|
|
|
30
30
|
<>
|
|
31
31
|
<button
|
|
32
32
|
type="button"
|
|
33
|
-
aria-label={`Chat with ${siteName}`}
|
|
33
|
+
aria-label={open ? "Collapse chat" : `Chat with ${siteName}`}
|
|
34
|
+
aria-expanded={open}
|
|
34
35
|
onClick={() => { setOpen((o) => !o); setEverOpened(true); }}
|
|
35
36
|
className={cn(
|
|
36
37
|
"fixed z-40 flex items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity",
|
|
37
38
|
variant === "editor"
|
|
38
|
-
? "bottom-
|
|
39
|
+
? "bottom-4 right-4 lg:right-auto h-10 w-10 fab-container-right"
|
|
39
40
|
: "bottom-4 right-4 h-12 w-12",
|
|
40
41
|
)}
|
|
41
42
|
>
|
|
42
|
-
|
|
43
|
-
<
|
|
44
|
-
|
|
43
|
+
{open ? (
|
|
44
|
+
<svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
45
|
+
<path d="m6 9 6 6 6-6" />
|
|
46
|
+
</svg>
|
|
47
|
+
) : (
|
|
48
|
+
<svg xmlns="http://www.w3.org/2000/svg" className={variant === "editor" ? "h-5 w-5" : "h-6 w-6"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
49
|
+
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
|
50
|
+
</svg>
|
|
51
|
+
)}
|
|
45
52
|
</button>
|
|
46
53
|
{everOpened && (
|
|
47
54
|
<Suspense fallback={null}>
|
|
@@ -190,7 +190,7 @@ export function BugReportFAB() {
|
|
|
190
190
|
type="button"
|
|
191
191
|
onClick={handleOpen}
|
|
192
192
|
aria-label="Report a bug"
|
|
193
|
-
className="cursor-pointer fixed bottom-
|
|
193
|
+
className="cursor-pointer fixed bottom-28 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
|
|
194
194
|
>
|
|
195
195
|
{/* Bug / beetle SVG icon */}
|
|
196
196
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
@@ -1696,7 +1696,7 @@ function EditorToolbar({
|
|
|
1696
1696
|
{!historyState && (
|
|
1697
1697
|
<button
|
|
1698
1698
|
onClick={toggleEditMode}
|
|
1699
|
-
className="cursor-pointer fixed bottom-
|
|
1699
|
+
className="cursor-pointer fixed bottom-16 right-4 lg:right-auto z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity fab-container-right"
|
|
1700
1700
|
aria-label={isEditMode ? "Switch to view mode" : "Switch to edit mode"}
|
|
1701
1701
|
>
|
|
1702
1702
|
{isEditMode ? (
|