radiant-docs 0.1.28 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/astro.config.mjs +4 -0
- package/template/package-lock.json +1204 -20
- package/template/package.json +9 -2
- package/template/src/assets/icons/sparkle.svg +22 -0
- package/template/src/components/Footer.astro +11 -3
- package/template/src/components/Header.astro +48 -16
- package/template/src/components/Search.astro +3 -3
- package/template/src/components/Sidebar.astro +1 -1
- package/template/src/components/SidebarDropdown.astro +2 -2
- package/template/src/components/chat/AskAiWidget.tsx +1771 -0
- package/template/src/components/user/Accordion.astro +12 -3
- package/template/src/content.config.ts +1 -1
- package/template/src/layouts/Layout.astro +57 -12
- package/template/src/styles/global.css +1 -0
- package/template/src/styles/vaul.css +255 -0
- package/template/tsconfig.json +4 -0
|
@@ -0,0 +1,1771 @@
|
|
|
1
|
+
import type { JSX } from "preact";
|
|
2
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
3
|
+
import { Icon } from "@iconify/react";
|
|
4
|
+
import Prism from "prismjs";
|
|
5
|
+
import "prismjs/components/prism-markup.js";
|
|
6
|
+
import "prismjs/components/prism-clike.js";
|
|
7
|
+
import "prismjs/components/prism-javascript.js";
|
|
8
|
+
import "prismjs/components/prism-typescript.js";
|
|
9
|
+
import "prismjs/components/prism-jsx.js";
|
|
10
|
+
import "prismjs/components/prism-tsx.js";
|
|
11
|
+
import "prismjs/components/prism-json.js";
|
|
12
|
+
import "prismjs/components/prism-markdown.js";
|
|
13
|
+
import "prismjs/components/prism-bash.js";
|
|
14
|
+
import "prismjs/components/prism-python.js";
|
|
15
|
+
import "prismjs/components/prism-yaml.js";
|
|
16
|
+
import "prismjs/components/prism-sql.js";
|
|
17
|
+
import "prismjs/components/prism-rust.js";
|
|
18
|
+
import "prismjs/components/prism-go.js";
|
|
19
|
+
import "prismjs/components/prism-java.js";
|
|
20
|
+
import "prismjs/components/prism-markup-templating.js";
|
|
21
|
+
import "prismjs/components/prism-php.js";
|
|
22
|
+
import "prismjs/components/prism-ruby.js";
|
|
23
|
+
import "prismjs/components/prism-css.js";
|
|
24
|
+
import "prismjs/components/prism-diff.js";
|
|
25
|
+
// import "prismjs/themes/prism.css";
|
|
26
|
+
import "prism-themes/themes/prism-one-light.css";
|
|
27
|
+
// import "@jongwooo/prism-theme-github/themes/prism-github-default-light.css";
|
|
28
|
+
import { unified } from "unified";
|
|
29
|
+
import remarkParse from "remark-parse";
|
|
30
|
+
import remarkGfm from "remark-gfm";
|
|
31
|
+
import remarkRehype from "remark-rehype";
|
|
32
|
+
import rehypeStringify from "rehype-stringify";
|
|
33
|
+
import { Drawer } from "vaul";
|
|
34
|
+
import rehypeExternalLinks from "../../lib/mdx/rehype-external-links";
|
|
35
|
+
import sparkleIcon from "../../assets/icons/sparkle.svg?url";
|
|
36
|
+
|
|
37
|
+
type AskAiWidgetProps = {
|
|
38
|
+
apiPath: string;
|
|
39
|
+
isChatAvailable: boolean;
|
|
40
|
+
unavailableMessage?: string;
|
|
41
|
+
devProxyToken?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ChatMessage = {
|
|
45
|
+
id: string;
|
|
46
|
+
role: "user" | "assistant";
|
|
47
|
+
content: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type AskAiStreamEvent = {
|
|
51
|
+
type?: string;
|
|
52
|
+
delta?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type ResizeHandlePointerEvent = JSX.TargetedPointerEvent<HTMLDivElement>;
|
|
56
|
+
type DesktopWidgetWheelEvent = JSX.TargetedWheelEvent<HTMLDivElement>;
|
|
57
|
+
type ChatInputKeyEvent = JSX.TargetedKeyboardEvent<HTMLTextAreaElement>;
|
|
58
|
+
|
|
59
|
+
type DesktopSize = {
|
|
60
|
+
width: number;
|
|
61
|
+
height: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type InlineStyleSnapshot = {
|
|
65
|
+
transform: string;
|
|
66
|
+
transformOrigin: string;
|
|
67
|
+
transitionProperty: string;
|
|
68
|
+
transitionDuration: string;
|
|
69
|
+
transitionTimingFunction: string;
|
|
70
|
+
overflow: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type PersistedWidgetState = {
|
|
74
|
+
isOpen: boolean;
|
|
75
|
+
messages: ChatMessage[];
|
|
76
|
+
input: string;
|
|
77
|
+
desktopWidth: number;
|
|
78
|
+
desktopHeight: number;
|
|
79
|
+
threadScrollTop: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const STORAGE_KEY = "radiant-docs:ask-ai-widget:v1";
|
|
83
|
+
const DESKTOP_MIN_WIDTH_PX = 320;
|
|
84
|
+
const DESKTOP_MAX_WIDTH_PX = 768 + 12 + 12;
|
|
85
|
+
const DESKTOP_DEFAULT_WIDTH_PX = 384;
|
|
86
|
+
const DESKTOP_MIN_HEIGHT_PX = 320;
|
|
87
|
+
const DESKTOP_DEFAULT_HEIGHT_PX = 448;
|
|
88
|
+
const DESKTOP_MAX_HEIGHT_OFFSET_PX = 20 + 4 + 8;
|
|
89
|
+
const MOBILE_DRAWER_TRANSITION_MS = 500;
|
|
90
|
+
const VAUL_SCALE_WINDOW_TOP_OFFSET_PX = 26;
|
|
91
|
+
const VAUL_SCALE_TRANSLATE_TOP_OFFSET_PX = 14;
|
|
92
|
+
const VAUL_SCALE_TRANSITION_EASE = "cubic-bezier(0.32, 0.72, 0, 1)";
|
|
93
|
+
const MARKDOWN_HTML_CACHE_LIMIT = 300;
|
|
94
|
+
const markdownHtmlCache = new Map<string, string>();
|
|
95
|
+
const PRISM_LANGUAGE_ALIAS: Record<string, string> = {
|
|
96
|
+
js: "javascript",
|
|
97
|
+
ts: "typescript",
|
|
98
|
+
yml: "yaml",
|
|
99
|
+
sh: "bash",
|
|
100
|
+
shell: "bash",
|
|
101
|
+
shellscript: "bash",
|
|
102
|
+
md: "markdown",
|
|
103
|
+
mdx: "markdown",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const PRISM_LANGUAGE_LABEL: Record<string, string> = {
|
|
107
|
+
javascript: "JavaScript",
|
|
108
|
+
typescript: "TypeScript",
|
|
109
|
+
jsx: "JSX",
|
|
110
|
+
tsx: "TSX",
|
|
111
|
+
json: "JSON",
|
|
112
|
+
markdown: "Markdown",
|
|
113
|
+
bash: "Bash",
|
|
114
|
+
shell: "Shell",
|
|
115
|
+
python: "Python",
|
|
116
|
+
yaml: "YAML",
|
|
117
|
+
sql: "SQL",
|
|
118
|
+
rust: "Rust",
|
|
119
|
+
go: "Go",
|
|
120
|
+
java: "Java",
|
|
121
|
+
css: "CSS",
|
|
122
|
+
html: "HTML",
|
|
123
|
+
diff: "Diff",
|
|
124
|
+
mdx: "MDX",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function clamp(value: number, min: number, max: number) {
|
|
128
|
+
return Math.min(Math.max(value, min), max);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getViewportHeightPx() {
|
|
132
|
+
if (typeof window === "undefined") {
|
|
133
|
+
return DESKTOP_DEFAULT_HEIGHT_PX + DESKTOP_MAX_HEIGHT_OFFSET_PX;
|
|
134
|
+
}
|
|
135
|
+
return window.visualViewport?.height ?? window.innerHeight;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getDesktopMaxHeightPx() {
|
|
139
|
+
return Math.max(
|
|
140
|
+
DESKTOP_MIN_HEIGHT_PX,
|
|
141
|
+
getViewportHeightPx() - DESKTOP_MAX_HEIGHT_OFFSET_PX,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getDefaultPersistedState(): PersistedWidgetState {
|
|
146
|
+
return {
|
|
147
|
+
isOpen: false,
|
|
148
|
+
messages: [],
|
|
149
|
+
input: "",
|
|
150
|
+
desktopWidth: DESKTOP_DEFAULT_WIDTH_PX,
|
|
151
|
+
desktopHeight: DESKTOP_DEFAULT_HEIGHT_PX,
|
|
152
|
+
threadScrollTop: 0,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readPersistedState(): PersistedWidgetState {
|
|
157
|
+
if (typeof window === "undefined") {
|
|
158
|
+
return getDefaultPersistedState();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
163
|
+
if (!raw) {
|
|
164
|
+
return getDefaultPersistedState();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const parsed = JSON.parse(raw) as Partial<PersistedWidgetState>;
|
|
168
|
+
const parsedMessages = Array.isArray(parsed.messages)
|
|
169
|
+
? parsed.messages
|
|
170
|
+
.filter((message) => {
|
|
171
|
+
if (!message || typeof message !== "object") {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
const candidate = message as Partial<ChatMessage>;
|
|
175
|
+
return (
|
|
176
|
+
(candidate.role === "user" || candidate.role === "assistant") &&
|
|
177
|
+
typeof candidate.id === "string" &&
|
|
178
|
+
typeof candidate.content === "string"
|
|
179
|
+
);
|
|
180
|
+
})
|
|
181
|
+
.map((message) => {
|
|
182
|
+
const candidate = message as ChatMessage;
|
|
183
|
+
return {
|
|
184
|
+
id: candidate.id,
|
|
185
|
+
role: candidate.role,
|
|
186
|
+
content: candidate.content,
|
|
187
|
+
};
|
|
188
|
+
})
|
|
189
|
+
: [];
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
isOpen: parsed.isOpen === true,
|
|
193
|
+
messages: parsedMessages,
|
|
194
|
+
input: typeof parsed.input === "string" ? parsed.input : "",
|
|
195
|
+
desktopWidth:
|
|
196
|
+
typeof parsed.desktopWidth === "number" &&
|
|
197
|
+
Number.isFinite(parsed.desktopWidth)
|
|
198
|
+
? parsed.desktopWidth
|
|
199
|
+
: DESKTOP_DEFAULT_WIDTH_PX,
|
|
200
|
+
desktopHeight:
|
|
201
|
+
typeof parsed.desktopHeight === "number" &&
|
|
202
|
+
Number.isFinite(parsed.desktopHeight)
|
|
203
|
+
? parsed.desktopHeight
|
|
204
|
+
: DESKTOP_DEFAULT_HEIGHT_PX,
|
|
205
|
+
threadScrollTop:
|
|
206
|
+
typeof parsed.threadScrollTop === "number" &&
|
|
207
|
+
Number.isFinite(parsed.threadScrollTop) &&
|
|
208
|
+
parsed.threadScrollTop >= 0
|
|
209
|
+
? parsed.threadScrollTop
|
|
210
|
+
: 0,
|
|
211
|
+
};
|
|
212
|
+
} catch {
|
|
213
|
+
return getDefaultPersistedState();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function escapeHtml(value: string): string {
|
|
218
|
+
return value
|
|
219
|
+
.replaceAll("&", "&")
|
|
220
|
+
.replaceAll("<", "<")
|
|
221
|
+
.replaceAll(">", ">")
|
|
222
|
+
.replaceAll('"', """)
|
|
223
|
+
.replaceAll("'", "'");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function resolvePrismLanguage(rawLanguage: string): string {
|
|
227
|
+
const normalized = rawLanguage.trim().toLowerCase();
|
|
228
|
+
return PRISM_LANGUAGE_ALIAS[normalized] ?? normalized;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function resolvePrismLanguageLabel(language: string): string {
|
|
232
|
+
const normalized = language.trim().toLowerCase();
|
|
233
|
+
if (!normalized) {
|
|
234
|
+
return "";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
PRISM_LANGUAGE_LABEL[normalized] ??
|
|
239
|
+
normalized
|
|
240
|
+
.replace(/[-_]+/g, " ")
|
|
241
|
+
.replace(/\b\w/g, (char) => char.toUpperCase())
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function resolvePrismGrammar(language: string) {
|
|
246
|
+
return (
|
|
247
|
+
Prism.languages[language] ??
|
|
248
|
+
Prism.languages[PRISM_LANGUAGE_ALIAS[language] ?? ""]
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function ensureCodeBlockCopyButton(preElement: HTMLPreElement): void {
|
|
253
|
+
const existingButton = Array.from(preElement.children).find(
|
|
254
|
+
(child) =>
|
|
255
|
+
child instanceof HTMLButtonElement &&
|
|
256
|
+
child.classList.contains("ask-ai-copy-code-button"),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (existingButton instanceof HTMLButtonElement) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const copyButton = document.createElement("button");
|
|
264
|
+
copyButton.type = "button";
|
|
265
|
+
copyButton.className = "ask-ai-copy-code-button";
|
|
266
|
+
copyButton.setAttribute("data-copy-code", "true");
|
|
267
|
+
copyButton.setAttribute("aria-label", "Copy code to clipboard");
|
|
268
|
+
copyButton.setAttribute("title", "Copy code");
|
|
269
|
+
|
|
270
|
+
const copyIcon = document.createElement("span");
|
|
271
|
+
copyIcon.className = "ask-ai-copy-icon";
|
|
272
|
+
copyIcon.setAttribute("aria-hidden", "true");
|
|
273
|
+
|
|
274
|
+
const checkIcon = document.createElement("span");
|
|
275
|
+
checkIcon.className = "ask-ai-copy-check-icon";
|
|
276
|
+
checkIcon.setAttribute("aria-hidden", "true");
|
|
277
|
+
|
|
278
|
+
copyButton.append(copyIcon, checkIcon);
|
|
279
|
+
preElement.append(copyButton);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function copyToClipboard(text: string): Promise<boolean> {
|
|
283
|
+
if (
|
|
284
|
+
typeof navigator !== "undefined" &&
|
|
285
|
+
navigator.clipboard &&
|
|
286
|
+
typeof navigator.clipboard.writeText === "function"
|
|
287
|
+
) {
|
|
288
|
+
try {
|
|
289
|
+
await navigator.clipboard.writeText(text);
|
|
290
|
+
return true;
|
|
291
|
+
} catch {
|
|
292
|
+
// Fall through to legacy copy fallback.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (typeof document === "undefined") {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const textarea = document.createElement("textarea");
|
|
302
|
+
textarea.value = text;
|
|
303
|
+
textarea.setAttribute("readonly", "true");
|
|
304
|
+
textarea.style.position = "fixed";
|
|
305
|
+
textarea.style.top = "-9999px";
|
|
306
|
+
textarea.style.opacity = "0";
|
|
307
|
+
document.body.append(textarea);
|
|
308
|
+
textarea.select();
|
|
309
|
+
const didCopy = document.execCommand("copy");
|
|
310
|
+
textarea.remove();
|
|
311
|
+
return didCopy;
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function unwrapEscapedFenceCodeBlock(rawCode: string): {
|
|
318
|
+
language: string;
|
|
319
|
+
code: string;
|
|
320
|
+
} | null {
|
|
321
|
+
const normalizedCode = rawCode.replaceAll("\r\n", "\n").trim();
|
|
322
|
+
if (!normalizedCode) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const lines = normalizedCode.split("\n");
|
|
327
|
+
if (lines.length < 2) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const openingLine = lines[0]?.trim() ?? "";
|
|
332
|
+
const openingMatch = openingLine.match(/^(`{3,})([^\s`]*)?(?:\s+[\s\S]*)?$/);
|
|
333
|
+
if (!openingMatch) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const fenceToken = openingMatch[1];
|
|
338
|
+
const closingLine = lines.at(-1)?.trim() ?? "";
|
|
339
|
+
if (closingLine !== fenceToken) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const languageToken = (openingMatch[2] ?? "").trim();
|
|
344
|
+
if (!languageToken) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
language: resolvePrismLanguage(languageToken),
|
|
350
|
+
code: lines.slice(1, -1).join("\n"),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function highlightCodeBlocksInHtml(html: string): string {
|
|
355
|
+
if (typeof document === "undefined" || !html.trim()) {
|
|
356
|
+
return html;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const container = document.createElement("div");
|
|
361
|
+
container.innerHTML = html;
|
|
362
|
+
|
|
363
|
+
const codeNodes = container.querySelectorAll("pre > code");
|
|
364
|
+
codeNodes.forEach((codeNode) => {
|
|
365
|
+
const getLanguageClassName = (element: Element | null) =>
|
|
366
|
+
Array.from(element?.classList ?? []).find(
|
|
367
|
+
(className) =>
|
|
368
|
+
className.startsWith("language-") || className.startsWith("lang-"),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const setLanguageClass = (language: string) => {
|
|
372
|
+
Array.from(codeNode.classList).forEach((className) => {
|
|
373
|
+
if (
|
|
374
|
+
className.startsWith("language-") ||
|
|
375
|
+
className.startsWith("lang-")
|
|
376
|
+
) {
|
|
377
|
+
codeNode.classList.remove(className);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
codeNode.classList.add(`language-${language}`);
|
|
381
|
+
|
|
382
|
+
const preParent = codeNode.parentElement;
|
|
383
|
+
if (preParent instanceof HTMLPreElement) {
|
|
384
|
+
Array.from(preParent.classList).forEach((className) => {
|
|
385
|
+
if (
|
|
386
|
+
className.startsWith("language-") ||
|
|
387
|
+
className.startsWith("lang-")
|
|
388
|
+
) {
|
|
389
|
+
preParent.classList.remove(className);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
preParent.classList.add(`language-${language}`);
|
|
393
|
+
const languageLabel = resolvePrismLanguageLabel(language);
|
|
394
|
+
if (languageLabel) {
|
|
395
|
+
preParent.setAttribute("data-language", languageLabel);
|
|
396
|
+
} else {
|
|
397
|
+
preParent.removeAttribute("data-language");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
ensureCodeBlockCopyButton(preParent);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const codeLanguageClassName = getLanguageClassName(codeNode);
|
|
405
|
+
const preLanguageClassName = getLanguageClassName(codeNode.parentElement);
|
|
406
|
+
let rawLanguage =
|
|
407
|
+
codeLanguageClassName?.replace(/^(language-|lang-)/, "") ??
|
|
408
|
+
preLanguageClassName?.replace(/^(language-|lang-)/, "") ??
|
|
409
|
+
"";
|
|
410
|
+
|
|
411
|
+
let rawCode = codeNode.textContent ?? "";
|
|
412
|
+
if (!rawLanguage) {
|
|
413
|
+
const unwrappedFence = unwrapEscapedFenceCodeBlock(rawCode);
|
|
414
|
+
if (unwrappedFence) {
|
|
415
|
+
rawLanguage = unwrappedFence.language || rawLanguage;
|
|
416
|
+
rawCode = unwrappedFence.code;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!rawLanguage) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const resolvedLanguage = resolvePrismLanguage(rawLanguage);
|
|
425
|
+
|
|
426
|
+
const grammar =
|
|
427
|
+
resolvePrismGrammar(resolvedLanguage) ??
|
|
428
|
+
resolvePrismGrammar(rawLanguage);
|
|
429
|
+
if (!grammar) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
setLanguageClass(resolvedLanguage);
|
|
434
|
+
try {
|
|
435
|
+
const highlighted = Prism.highlight(rawCode, grammar, resolvedLanguage);
|
|
436
|
+
codeNode.innerHTML = highlighted;
|
|
437
|
+
} catch (error) {
|
|
438
|
+
console.error("Ask AI code highlighting failed", {
|
|
439
|
+
language: resolvedLanguage,
|
|
440
|
+
error,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return container.innerHTML;
|
|
446
|
+
} catch (error) {
|
|
447
|
+
console.error("Ask AI markdown highlighting pipeline failed", error);
|
|
448
|
+
return html;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function renderMarkdownToHtml(markdown: string): string {
|
|
453
|
+
const normalizedMarkdown = markdown.replaceAll("\r\n", "\n");
|
|
454
|
+
const cached = markdownHtmlCache.get(normalizedMarkdown);
|
|
455
|
+
if (cached !== undefined) {
|
|
456
|
+
return cached;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let html = "";
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
html = String(
|
|
463
|
+
unified()
|
|
464
|
+
.use(remarkParse)
|
|
465
|
+
.use(remarkGfm)
|
|
466
|
+
.use(remarkRehype, { allowDangerousHtml: false })
|
|
467
|
+
.use(rehypeExternalLinks)
|
|
468
|
+
.use(rehypeStringify)
|
|
469
|
+
.processSync(normalizedMarkdown),
|
|
470
|
+
);
|
|
471
|
+
} catch {
|
|
472
|
+
html = escapeHtml(normalizedMarkdown).replaceAll("\n", "<br/>");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
html = highlightCodeBlocksInHtml(html);
|
|
476
|
+
|
|
477
|
+
markdownHtmlCache.set(normalizedMarkdown, html);
|
|
478
|
+
if (markdownHtmlCache.size > MARKDOWN_HTML_CACHE_LIMIT) {
|
|
479
|
+
const oldestKey = markdownHtmlCache.keys().next().value;
|
|
480
|
+
if (oldestKey) {
|
|
481
|
+
markdownHtmlCache.delete(oldestKey);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return html;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function createMessageId() {
|
|
489
|
+
if (
|
|
490
|
+
typeof crypto !== "undefined" &&
|
|
491
|
+
typeof crypto.randomUUID === "function"
|
|
492
|
+
) {
|
|
493
|
+
return crypto.randomUUID();
|
|
494
|
+
}
|
|
495
|
+
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function extractErrorMessage(rawBody: string) {
|
|
499
|
+
if (!rawBody.trim()) {
|
|
500
|
+
return "";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
const parsed = JSON.parse(rawBody) as { error?: unknown; detail?: unknown };
|
|
505
|
+
if (typeof parsed.error === "string" && parsed.error.trim()) {
|
|
506
|
+
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
|
|
507
|
+
return `${parsed.error}: ${parsed.detail}`;
|
|
508
|
+
}
|
|
509
|
+
return parsed.error;
|
|
510
|
+
}
|
|
511
|
+
} catch {
|
|
512
|
+
// Ignore JSON parse errors and fall back to raw text.
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return rawBody.trim();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export default function AskAiWidget({
|
|
519
|
+
apiPath,
|
|
520
|
+
isChatAvailable,
|
|
521
|
+
unavailableMessage = "This feature is available on production sites for Pro tier organizations.",
|
|
522
|
+
devProxyToken,
|
|
523
|
+
}: AskAiWidgetProps) {
|
|
524
|
+
const [initialPersistedState] = useState(readPersistedState);
|
|
525
|
+
const [isOpen, setIsOpen] = useState(initialPersistedState.isOpen);
|
|
526
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
527
|
+
const [desktopSize, setDesktopSize] = useState<DesktopSize>({
|
|
528
|
+
width: clamp(
|
|
529
|
+
initialPersistedState.desktopWidth,
|
|
530
|
+
DESKTOP_MIN_WIDTH_PX,
|
|
531
|
+
DESKTOP_MAX_WIDTH_PX,
|
|
532
|
+
),
|
|
533
|
+
height: initialPersistedState.desktopHeight,
|
|
534
|
+
});
|
|
535
|
+
const [messages, setMessages] = useState<ChatMessage[]>(
|
|
536
|
+
initialPersistedState.messages,
|
|
537
|
+
);
|
|
538
|
+
const [input, setInput] = useState(initialPersistedState.input);
|
|
539
|
+
const [threadScrollTop, setThreadScrollTop] = useState(
|
|
540
|
+
initialPersistedState.threadScrollTop,
|
|
541
|
+
);
|
|
542
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
543
|
+
const [isAwaitingFirstToken, setIsAwaitingFirstToken] = useState(false);
|
|
544
|
+
const [errorMessage, setErrorMessage] = useState("");
|
|
545
|
+
const activeRequestAbortRef = useRef<AbortController | null>(null);
|
|
546
|
+
const resizeModeRef = useRef<"left" | "top" | "corner" | null>(null);
|
|
547
|
+
const resizeStartPointerRef = useRef<{ x: number; y: number } | null>(null);
|
|
548
|
+
const resizeStartSizeRef = useRef<DesktopSize | null>(null);
|
|
549
|
+
const desktopScrollViewportRef = useRef<HTMLDivElement | null>(null);
|
|
550
|
+
const chatInputTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
551
|
+
const bodyBackgroundBeforeMobileDrawerRef = useRef<string | null>(null);
|
|
552
|
+
const bodyBackgroundResetTimeoutRef = useRef<number | null>(null);
|
|
553
|
+
const chromeScaleOriginalStylesRef = useRef<
|
|
554
|
+
Map<HTMLElement, InlineStyleSnapshot>
|
|
555
|
+
>(new Map());
|
|
556
|
+
const chromeScaleResetTimeoutRef = useRef<number | null>(null);
|
|
557
|
+
const threadScrollTopRef = useRef(initialPersistedState.threadScrollTop);
|
|
558
|
+
const hasRestoredThreadScrollRef = useRef(false);
|
|
559
|
+
|
|
560
|
+
const readCurrentThreadScrollTop = () => {
|
|
561
|
+
const viewport = desktopScrollViewportRef.current;
|
|
562
|
+
if (!viewport) {
|
|
563
|
+
return threadScrollTopRef.current;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const maxScrollTop = Math.max(
|
|
567
|
+
0,
|
|
568
|
+
viewport.scrollHeight - viewport.clientHeight,
|
|
569
|
+
);
|
|
570
|
+
return clamp(viewport.scrollTop, 0, maxScrollTop);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const captureThreadScrollTop = () => {
|
|
574
|
+
const nextThreadScrollTop = readCurrentThreadScrollTop();
|
|
575
|
+
threadScrollTopRef.current = nextThreadScrollTop;
|
|
576
|
+
setThreadScrollTop((previous) =>
|
|
577
|
+
Math.abs(previous - nextThreadScrollTop) < 0.5
|
|
578
|
+
? previous
|
|
579
|
+
: nextThreadScrollTop,
|
|
580
|
+
);
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const handleOpenChange = (nextOpen: boolean) => {
|
|
584
|
+
if (!nextOpen) {
|
|
585
|
+
captureThreadScrollTop();
|
|
586
|
+
hasRestoredThreadScrollRef.current = false;
|
|
587
|
+
} else {
|
|
588
|
+
hasRestoredThreadScrollRef.current = false;
|
|
589
|
+
}
|
|
590
|
+
setIsOpen(nextOpen);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const handleThreadViewportScroll = () => {
|
|
594
|
+
threadScrollTopRef.current = readCurrentThreadScrollTop();
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
useEffect(() => {
|
|
598
|
+
return () => {
|
|
599
|
+
activeRequestAbortRef.current?.abort();
|
|
600
|
+
activeRequestAbortRef.current = null;
|
|
601
|
+
};
|
|
602
|
+
}, []);
|
|
603
|
+
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
const mediaQuery = window.matchMedia("(max-width: 1023px)");
|
|
606
|
+
const update = () => {
|
|
607
|
+
setIsMobile(mediaQuery.matches);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
update();
|
|
611
|
+
|
|
612
|
+
if (typeof mediaQuery.addEventListener === "function") {
|
|
613
|
+
mediaQuery.addEventListener("change", update);
|
|
614
|
+
return () => mediaQuery.removeEventListener("change", update);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
mediaQuery.addListener(update);
|
|
618
|
+
return () => mediaQuery.removeListener(update);
|
|
619
|
+
}, []);
|
|
620
|
+
|
|
621
|
+
useEffect(() => {
|
|
622
|
+
const clampDesktopSizeToViewport = () => {
|
|
623
|
+
setDesktopSize((previous) => {
|
|
624
|
+
const maxHeight = getDesktopMaxHeightPx();
|
|
625
|
+
const nextWidth = clamp(
|
|
626
|
+
previous.width,
|
|
627
|
+
DESKTOP_MIN_WIDTH_PX,
|
|
628
|
+
DESKTOP_MAX_WIDTH_PX,
|
|
629
|
+
);
|
|
630
|
+
const nextHeight = clamp(
|
|
631
|
+
previous.height,
|
|
632
|
+
DESKTOP_MIN_HEIGHT_PX,
|
|
633
|
+
maxHeight,
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
if (nextWidth === previous.width && nextHeight === previous.height) {
|
|
637
|
+
return previous;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
width: nextWidth,
|
|
642
|
+
height: nextHeight,
|
|
643
|
+
};
|
|
644
|
+
});
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
clampDesktopSizeToViewport();
|
|
648
|
+
window.addEventListener("resize", clampDesktopSizeToViewport);
|
|
649
|
+
window.visualViewport?.addEventListener(
|
|
650
|
+
"resize",
|
|
651
|
+
clampDesktopSizeToViewport,
|
|
652
|
+
);
|
|
653
|
+
return () => {
|
|
654
|
+
window.removeEventListener("resize", clampDesktopSizeToViewport);
|
|
655
|
+
window.visualViewport?.removeEventListener(
|
|
656
|
+
"resize",
|
|
657
|
+
clampDesktopSizeToViewport,
|
|
658
|
+
);
|
|
659
|
+
};
|
|
660
|
+
}, []);
|
|
661
|
+
|
|
662
|
+
useEffect(() => {
|
|
663
|
+
const openFromHeader = () => {
|
|
664
|
+
handleOpenChange(true);
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
window.addEventListener("ask-ai:open", openFromHeader);
|
|
668
|
+
return () => {
|
|
669
|
+
window.removeEventListener("ask-ai:open", openFromHeader);
|
|
670
|
+
};
|
|
671
|
+
}, []);
|
|
672
|
+
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
if (typeof window === "undefined" || !isOpen) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (hasRestoredThreadScrollRef.current) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const viewport = desktopScrollViewportRef.current;
|
|
683
|
+
if (!viewport) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const restoreFrame = window.requestAnimationFrame(() => {
|
|
688
|
+
const maxScrollTop = Math.max(
|
|
689
|
+
0,
|
|
690
|
+
viewport.scrollHeight - viewport.clientHeight,
|
|
691
|
+
);
|
|
692
|
+
const nextScrollTop = clamp(threadScrollTopRef.current, 0, maxScrollTop);
|
|
693
|
+
viewport.scrollTop = nextScrollTop;
|
|
694
|
+
threadScrollTopRef.current = nextScrollTop;
|
|
695
|
+
hasRestoredThreadScrollRef.current = true;
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
return () => {
|
|
699
|
+
window.cancelAnimationFrame(restoreFrame);
|
|
700
|
+
};
|
|
701
|
+
}, [isOpen, isMobile, messages.length]);
|
|
702
|
+
|
|
703
|
+
useEffect(() => {
|
|
704
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (chromeScaleResetTimeoutRef.current !== null) {
|
|
709
|
+
window.clearTimeout(chromeScaleResetTimeoutRef.current);
|
|
710
|
+
chromeScaleResetTimeoutRef.current = null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const chromeElements = Array.from(
|
|
714
|
+
document.querySelectorAll<HTMLElement>("[data-vaul-scale-chrome]"),
|
|
715
|
+
);
|
|
716
|
+
const sharedChromePivotX = window.innerWidth / 2;
|
|
717
|
+
const sharedChromePivotY = 0;
|
|
718
|
+
const resolveSharedChromeTransformOrigin = (element: HTMLElement) => {
|
|
719
|
+
const rect = element.getBoundingClientRect();
|
|
720
|
+
return `${sharedChromePivotX - rect.left}px ${sharedChromePivotY - rect.top}px`;
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const hasConnectedSnapshotElement = Array.from(
|
|
724
|
+
chromeScaleOriginalStylesRef.current.keys(),
|
|
725
|
+
).some((element) => element.isConnected);
|
|
726
|
+
|
|
727
|
+
if (isMobile && isOpen) {
|
|
728
|
+
if (
|
|
729
|
+
chromeScaleOriginalStylesRef.current.size === 0 ||
|
|
730
|
+
!hasConnectedSnapshotElement
|
|
731
|
+
) {
|
|
732
|
+
chromeScaleOriginalStylesRef.current = new Map(
|
|
733
|
+
chromeElements.map((element) => [
|
|
734
|
+
element,
|
|
735
|
+
{
|
|
736
|
+
transform: element.style.transform,
|
|
737
|
+
transformOrigin: element.style.transformOrigin,
|
|
738
|
+
transitionProperty: element.style.transitionProperty,
|
|
739
|
+
transitionDuration: element.style.transitionDuration,
|
|
740
|
+
transitionTimingFunction: element.style.transitionTimingFunction,
|
|
741
|
+
overflow: element.style.overflow,
|
|
742
|
+
},
|
|
743
|
+
]),
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const wrapperScale =
|
|
748
|
+
(window.innerWidth - VAUL_SCALE_WINDOW_TOP_OFFSET_PX) /
|
|
749
|
+
window.innerWidth;
|
|
750
|
+
const nextTransform = `scale(${wrapperScale}) translate3d(0, calc(env(safe-area-inset-top) + ${VAUL_SCALE_TRANSLATE_TOP_OFFSET_PX}px), 0)`;
|
|
751
|
+
|
|
752
|
+
for (const element of chromeElements) {
|
|
753
|
+
element.style.transformOrigin =
|
|
754
|
+
resolveSharedChromeTransformOrigin(element);
|
|
755
|
+
element.style.transitionProperty = "transform";
|
|
756
|
+
element.style.transitionDuration = `${MOBILE_DRAWER_TRANSITION_MS}ms`;
|
|
757
|
+
element.style.transitionTimingFunction = VAUL_SCALE_TRANSITION_EASE;
|
|
758
|
+
element.style.transform = nextTransform;
|
|
759
|
+
element.style.overflow = "hidden";
|
|
760
|
+
}
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (chromeScaleOriginalStylesRef.current.size === 0) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const snapshots = chromeScaleOriginalStylesRef.current;
|
|
769
|
+
for (const [element, snapshot] of snapshots) {
|
|
770
|
+
if (!element.isConnected) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
element.style.transitionProperty = "transform";
|
|
775
|
+
element.style.transitionDuration = `${MOBILE_DRAWER_TRANSITION_MS}ms`;
|
|
776
|
+
element.style.transitionTimingFunction = VAUL_SCALE_TRANSITION_EASE;
|
|
777
|
+
element.style.transform = snapshot.transform;
|
|
778
|
+
element.style.overflow = snapshot.overflow;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
chromeScaleResetTimeoutRef.current = window.setTimeout(() => {
|
|
782
|
+
for (const [element, snapshot] of snapshots) {
|
|
783
|
+
if (!element.isConnected) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
element.style.transform = snapshot.transform;
|
|
787
|
+
element.style.transformOrigin = snapshot.transformOrigin;
|
|
788
|
+
element.style.transitionProperty = snapshot.transitionProperty;
|
|
789
|
+
element.style.transitionDuration = snapshot.transitionDuration;
|
|
790
|
+
element.style.transitionTimingFunction =
|
|
791
|
+
snapshot.transitionTimingFunction;
|
|
792
|
+
element.style.overflow = snapshot.overflow;
|
|
793
|
+
}
|
|
794
|
+
chromeScaleOriginalStylesRef.current = new Map();
|
|
795
|
+
chromeScaleResetTimeoutRef.current = null;
|
|
796
|
+
}, MOBILE_DRAWER_TRANSITION_MS);
|
|
797
|
+
}, [isMobile, isOpen]);
|
|
798
|
+
|
|
799
|
+
useEffect(() => {
|
|
800
|
+
if (typeof document === "undefined") {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (typeof window === "undefined") {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (bodyBackgroundResetTimeoutRef.current !== null) {
|
|
809
|
+
window.clearTimeout(bodyBackgroundResetTimeoutRef.current);
|
|
810
|
+
bodyBackgroundResetTimeoutRef.current = null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (isMobile && isOpen) {
|
|
814
|
+
if (bodyBackgroundBeforeMobileDrawerRef.current === null) {
|
|
815
|
+
bodyBackgroundBeforeMobileDrawerRef.current =
|
|
816
|
+
document.body.style.backgroundColor;
|
|
817
|
+
}
|
|
818
|
+
document.body.style.backgroundColor = "var(--color-neutral-100)";
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (bodyBackgroundBeforeMobileDrawerRef.current === null) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const previousBodyBackgroundColor =
|
|
827
|
+
bodyBackgroundBeforeMobileDrawerRef.current;
|
|
828
|
+
bodyBackgroundResetTimeoutRef.current = window.setTimeout(() => {
|
|
829
|
+
document.body.style.backgroundColor = previousBodyBackgroundColor;
|
|
830
|
+
bodyBackgroundBeforeMobileDrawerRef.current = null;
|
|
831
|
+
bodyBackgroundResetTimeoutRef.current = null;
|
|
832
|
+
}, MOBILE_DRAWER_TRANSITION_MS);
|
|
833
|
+
}, [isMobile, isOpen]);
|
|
834
|
+
|
|
835
|
+
useEffect(() => {
|
|
836
|
+
return () => {
|
|
837
|
+
if (
|
|
838
|
+
typeof window !== "undefined" &&
|
|
839
|
+
bodyBackgroundResetTimeoutRef.current !== null
|
|
840
|
+
) {
|
|
841
|
+
window.clearTimeout(bodyBackgroundResetTimeoutRef.current);
|
|
842
|
+
bodyBackgroundResetTimeoutRef.current = null;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (
|
|
846
|
+
typeof window !== "undefined" &&
|
|
847
|
+
chromeScaleResetTimeoutRef.current !== null
|
|
848
|
+
) {
|
|
849
|
+
window.clearTimeout(chromeScaleResetTimeoutRef.current);
|
|
850
|
+
chromeScaleResetTimeoutRef.current = null;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (
|
|
854
|
+
typeof document !== "undefined" &&
|
|
855
|
+
bodyBackgroundBeforeMobileDrawerRef.current !== null
|
|
856
|
+
) {
|
|
857
|
+
document.body.style.backgroundColor =
|
|
858
|
+
bodyBackgroundBeforeMobileDrawerRef.current;
|
|
859
|
+
bodyBackgroundBeforeMobileDrawerRef.current = null;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
for (const [element, snapshot] of chromeScaleOriginalStylesRef.current) {
|
|
863
|
+
if (!element.isConnected) {
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
element.style.transform = snapshot.transform;
|
|
867
|
+
element.style.transformOrigin = snapshot.transformOrigin;
|
|
868
|
+
element.style.transitionProperty = snapshot.transitionProperty;
|
|
869
|
+
element.style.transitionDuration = snapshot.transitionDuration;
|
|
870
|
+
element.style.transitionTimingFunction =
|
|
871
|
+
snapshot.transitionTimingFunction;
|
|
872
|
+
element.style.overflow = snapshot.overflow;
|
|
873
|
+
}
|
|
874
|
+
chromeScaleOriginalStylesRef.current = new Map();
|
|
875
|
+
};
|
|
876
|
+
}, []);
|
|
877
|
+
|
|
878
|
+
useEffect(() => {
|
|
879
|
+
if (typeof window === "undefined") {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const timeoutId = window.setTimeout(() => {
|
|
884
|
+
try {
|
|
885
|
+
const persistedState: PersistedWidgetState = {
|
|
886
|
+
isOpen,
|
|
887
|
+
messages,
|
|
888
|
+
input,
|
|
889
|
+
desktopWidth: desktopSize.width,
|
|
890
|
+
desktopHeight: desktopSize.height,
|
|
891
|
+
threadScrollTop,
|
|
892
|
+
};
|
|
893
|
+
window.localStorage.setItem(
|
|
894
|
+
STORAGE_KEY,
|
|
895
|
+
JSON.stringify(persistedState),
|
|
896
|
+
);
|
|
897
|
+
} catch {
|
|
898
|
+
// Ignore storage write errors (quota/private mode).
|
|
899
|
+
}
|
|
900
|
+
}, 120);
|
|
901
|
+
|
|
902
|
+
return () => {
|
|
903
|
+
window.clearTimeout(timeoutId);
|
|
904
|
+
};
|
|
905
|
+
}, [
|
|
906
|
+
isOpen,
|
|
907
|
+
messages,
|
|
908
|
+
input,
|
|
909
|
+
desktopSize.width,
|
|
910
|
+
desktopSize.height,
|
|
911
|
+
threadScrollTop,
|
|
912
|
+
]);
|
|
913
|
+
|
|
914
|
+
useEffect(() => {
|
|
915
|
+
const finishResize = () => {
|
|
916
|
+
if (resizeModeRef.current) {
|
|
917
|
+
resizeModeRef.current = null;
|
|
918
|
+
resizeStartPointerRef.current = null;
|
|
919
|
+
resizeStartSizeRef.current = null;
|
|
920
|
+
document.body.style.userSelect = "";
|
|
921
|
+
document.body.style.cursor = "";
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
926
|
+
if (
|
|
927
|
+
!resizeModeRef.current ||
|
|
928
|
+
!resizeStartPointerRef.current ||
|
|
929
|
+
!resizeStartSizeRef.current
|
|
930
|
+
) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const deltaX = event.clientX - resizeStartPointerRef.current.x;
|
|
935
|
+
const deltaY = event.clientY - resizeStartPointerRef.current.y;
|
|
936
|
+
const mode = resizeModeRef.current;
|
|
937
|
+
const maxHeight = getDesktopMaxHeightPx();
|
|
938
|
+
|
|
939
|
+
let nextWidth = resizeStartSizeRef.current.width;
|
|
940
|
+
let nextHeight = resizeStartSizeRef.current.height;
|
|
941
|
+
|
|
942
|
+
if (mode === "left" || mode === "corner") {
|
|
943
|
+
nextWidth = clamp(
|
|
944
|
+
resizeStartSizeRef.current.width - deltaX,
|
|
945
|
+
DESKTOP_MIN_WIDTH_PX,
|
|
946
|
+
DESKTOP_MAX_WIDTH_PX,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (mode === "top" || mode === "corner") {
|
|
951
|
+
nextHeight = clamp(
|
|
952
|
+
resizeStartSizeRef.current.height - deltaY,
|
|
953
|
+
DESKTOP_MIN_HEIGHT_PX,
|
|
954
|
+
maxHeight,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
setDesktopSize((previous) => {
|
|
959
|
+
if (previous.width === nextWidth && previous.height === nextHeight) {
|
|
960
|
+
return previous;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return {
|
|
964
|
+
width: nextWidth,
|
|
965
|
+
height: nextHeight,
|
|
966
|
+
};
|
|
967
|
+
});
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
window.addEventListener("pointermove", handlePointerMove);
|
|
971
|
+
window.addEventListener("pointerup", finishResize);
|
|
972
|
+
window.addEventListener("pointercancel", finishResize);
|
|
973
|
+
return () => {
|
|
974
|
+
window.removeEventListener("pointermove", handlePointerMove);
|
|
975
|
+
window.removeEventListener("pointerup", finishResize);
|
|
976
|
+
window.removeEventListener("pointercancel", finishResize);
|
|
977
|
+
};
|
|
978
|
+
}, []);
|
|
979
|
+
|
|
980
|
+
const beginDesktopResize =
|
|
981
|
+
(mode: "left" | "top" | "corner") => (event: ResizeHandlePointerEvent) => {
|
|
982
|
+
if (isMobile) {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
event.preventDefault();
|
|
987
|
+
event.stopPropagation();
|
|
988
|
+
|
|
989
|
+
resizeModeRef.current = mode;
|
|
990
|
+
resizeStartPointerRef.current = {
|
|
991
|
+
x: event.clientX,
|
|
992
|
+
y: event.clientY,
|
|
993
|
+
};
|
|
994
|
+
resizeStartSizeRef.current = {
|
|
995
|
+
width: desktopSize.width,
|
|
996
|
+
height: desktopSize.height,
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
document.body.style.userSelect = "none";
|
|
1000
|
+
document.body.style.cursor =
|
|
1001
|
+
mode === "left"
|
|
1002
|
+
? "ew-resize"
|
|
1003
|
+
: mode === "top"
|
|
1004
|
+
? "ns-resize"
|
|
1005
|
+
: "nwse-resize";
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
const handleDesktopWheelCapture = (event: DesktopWidgetWheelEvent) => {
|
|
1009
|
+
if (isMobile || !isOpen) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const inputTextarea = chatInputTextareaRef.current;
|
|
1014
|
+
let remainingDeltaY = event.deltaY;
|
|
1015
|
+
const eventTargetElement =
|
|
1016
|
+
event.target instanceof Element ? event.target : null;
|
|
1017
|
+
|
|
1018
|
+
if (
|
|
1019
|
+
inputTextarea &&
|
|
1020
|
+
event.target instanceof Node &&
|
|
1021
|
+
inputTextarea.contains(event.target)
|
|
1022
|
+
) {
|
|
1023
|
+
const inputMaxScrollTop =
|
|
1024
|
+
inputTextarea.scrollHeight - inputTextarea.clientHeight;
|
|
1025
|
+
|
|
1026
|
+
event.preventDefault();
|
|
1027
|
+
|
|
1028
|
+
if (inputMaxScrollTop > 0) {
|
|
1029
|
+
const previousInputScrollTop = inputTextarea.scrollTop;
|
|
1030
|
+
const nextInputScrollTop = clamp(
|
|
1031
|
+
previousInputScrollTop + remainingDeltaY,
|
|
1032
|
+
0,
|
|
1033
|
+
inputMaxScrollTop,
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
inputTextarea.scrollTop = nextInputScrollTop;
|
|
1037
|
+
remainingDeltaY -= nextInputScrollTop - previousInputScrollTop;
|
|
1038
|
+
|
|
1039
|
+
if (Math.abs(remainingDeltaY) < 0.5) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const codeBlockPre = eventTargetElement?.closest(
|
|
1046
|
+
".ask-ai-markdown pre",
|
|
1047
|
+
) as HTMLPreElement | null;
|
|
1048
|
+
|
|
1049
|
+
if (codeBlockPre) {
|
|
1050
|
+
const maxScrollLeft = Math.max(
|
|
1051
|
+
0,
|
|
1052
|
+
codeBlockPre.scrollWidth - codeBlockPre.clientWidth,
|
|
1053
|
+
);
|
|
1054
|
+
const horizontalIntentDelta =
|
|
1055
|
+
Math.abs(event.deltaX) > 0.01
|
|
1056
|
+
? event.deltaX
|
|
1057
|
+
: event.shiftKey
|
|
1058
|
+
? event.deltaY
|
|
1059
|
+
: 0;
|
|
1060
|
+
|
|
1061
|
+
if (Math.abs(horizontalIntentDelta) > 0.01) {
|
|
1062
|
+
event.preventDefault();
|
|
1063
|
+
|
|
1064
|
+
if (maxScrollLeft > 0) {
|
|
1065
|
+
codeBlockPre.scrollLeft = clamp(
|
|
1066
|
+
codeBlockPre.scrollLeft + horizontalIntentDelta,
|
|
1067
|
+
0,
|
|
1068
|
+
maxScrollLeft,
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Shift+wheel maps to horizontal intent; don't also move chat vertically.
|
|
1073
|
+
if (event.shiftKey && Math.abs(event.deltaX) <= 0.01) {
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const scrollViewport = desktopScrollViewportRef.current;
|
|
1080
|
+
if (!scrollViewport) {
|
|
1081
|
+
event.preventDefault();
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
event.preventDefault();
|
|
1086
|
+
|
|
1087
|
+
const maxScrollTop =
|
|
1088
|
+
scrollViewport.scrollHeight - scrollViewport.clientHeight;
|
|
1089
|
+
if (maxScrollTop <= 0) {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const nextDeltaY =
|
|
1094
|
+
Math.abs(remainingDeltaY) < 0.5 ? event.deltaY : remainingDeltaY;
|
|
1095
|
+
|
|
1096
|
+
scrollViewport.scrollTop = clamp(
|
|
1097
|
+
scrollViewport.scrollTop + nextDeltaY,
|
|
1098
|
+
0,
|
|
1099
|
+
maxScrollTop,
|
|
1100
|
+
);
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
const resizeChatInputTextarea = () => {
|
|
1104
|
+
const textarea = chatInputTextareaRef.current;
|
|
1105
|
+
if (!textarea || typeof window === "undefined") {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
textarea.style.height = "auto";
|
|
1110
|
+
|
|
1111
|
+
const computedStyle = window.getComputedStyle(textarea);
|
|
1112
|
+
const lineHeightRaw = Number.parseFloat(computedStyle.lineHeight);
|
|
1113
|
+
const paddingTopRaw = Number.parseFloat(computedStyle.paddingTop);
|
|
1114
|
+
const paddingBottomRaw = Number.parseFloat(computedStyle.paddingBottom);
|
|
1115
|
+
|
|
1116
|
+
const lineHeight = Number.isFinite(lineHeightRaw) ? lineHeightRaw : 20;
|
|
1117
|
+
const paddingTop = Number.isFinite(paddingTopRaw) ? paddingTopRaw : 0;
|
|
1118
|
+
const paddingBottom = Number.isFinite(paddingBottomRaw)
|
|
1119
|
+
? paddingBottomRaw
|
|
1120
|
+
: 0;
|
|
1121
|
+
|
|
1122
|
+
const maxHeight = lineHeight * 11.2 + paddingTop + paddingBottom;
|
|
1123
|
+
const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
|
|
1124
|
+
|
|
1125
|
+
textarea.style.height = `${nextHeight}px`;
|
|
1126
|
+
textarea.style.overflowY =
|
|
1127
|
+
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
useEffect(() => {
|
|
1131
|
+
resizeChatInputTextarea();
|
|
1132
|
+
}, [input, isOpen, isMobile, desktopSize.width]);
|
|
1133
|
+
|
|
1134
|
+
const handleSubmit = async (event: Event) => {
|
|
1135
|
+
event.preventDefault();
|
|
1136
|
+
const prompt = input.trim();
|
|
1137
|
+
if (!prompt || isBusy) {
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
setErrorMessage("");
|
|
1142
|
+
setIsBusy(true);
|
|
1143
|
+
setIsAwaitingFirstToken(true);
|
|
1144
|
+
|
|
1145
|
+
const userMessage: ChatMessage = {
|
|
1146
|
+
id: createMessageId(),
|
|
1147
|
+
role: "user",
|
|
1148
|
+
content: prompt,
|
|
1149
|
+
};
|
|
1150
|
+
const assistantId = createMessageId();
|
|
1151
|
+
const nextConversation = [...messages, userMessage];
|
|
1152
|
+
|
|
1153
|
+
setInput("");
|
|
1154
|
+
setMessages(nextConversation);
|
|
1155
|
+
|
|
1156
|
+
activeRequestAbortRef.current?.abort();
|
|
1157
|
+
const abortController = new AbortController();
|
|
1158
|
+
activeRequestAbortRef.current = abortController;
|
|
1159
|
+
|
|
1160
|
+
try {
|
|
1161
|
+
const requestHeaders: Record<string, string> = {
|
|
1162
|
+
"Content-Type": "application/json",
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
if (devProxyToken && devProxyToken.trim().length > 0) {
|
|
1166
|
+
requestHeaders["x-ask-ai-dev-token"] = devProxyToken;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const response = await fetch(apiPath, {
|
|
1170
|
+
method: "POST",
|
|
1171
|
+
headers: requestHeaders,
|
|
1172
|
+
body: JSON.stringify({
|
|
1173
|
+
messages: nextConversation.map((message) => ({
|
|
1174
|
+
role: message.role,
|
|
1175
|
+
content: message.content,
|
|
1176
|
+
})),
|
|
1177
|
+
}),
|
|
1178
|
+
signal: abortController.signal,
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
if (!response.ok) {
|
|
1182
|
+
const rawBody = await response.text();
|
|
1183
|
+
const detail = extractErrorMessage(rawBody);
|
|
1184
|
+
throw new Error(
|
|
1185
|
+
detail || `Request failed with status ${response.status}`,
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (!response.body) {
|
|
1190
|
+
throw new Error("Response body is empty");
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const reader = response.body.getReader();
|
|
1194
|
+
const decoder = new TextDecoder();
|
|
1195
|
+
let buffer = "";
|
|
1196
|
+
let streamDone = false;
|
|
1197
|
+
let hasReceivedFirstTextDelta = false;
|
|
1198
|
+
|
|
1199
|
+
while (!streamDone) {
|
|
1200
|
+
const { done, value } = await reader.read();
|
|
1201
|
+
if (done) {
|
|
1202
|
+
break;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1206
|
+
|
|
1207
|
+
while (true) {
|
|
1208
|
+
const eventBreakIndex = buffer.indexOf("\n\n");
|
|
1209
|
+
if (eventBreakIndex === -1) {
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const rawEvent = buffer.slice(0, eventBreakIndex);
|
|
1214
|
+
buffer = buffer.slice(eventBreakIndex + 2);
|
|
1215
|
+
|
|
1216
|
+
const dataPayload = rawEvent
|
|
1217
|
+
.split("\n")
|
|
1218
|
+
.filter((line) => line.startsWith("data:"))
|
|
1219
|
+
.map((line) => line.slice(5).trimStart())
|
|
1220
|
+
.join("\n");
|
|
1221
|
+
|
|
1222
|
+
if (!dataPayload) {
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (dataPayload === "[DONE]") {
|
|
1227
|
+
streamDone = true;
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
let parsed: AskAiStreamEvent;
|
|
1232
|
+
try {
|
|
1233
|
+
parsed = JSON.parse(dataPayload) as AskAiStreamEvent;
|
|
1234
|
+
} catch {
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (
|
|
1239
|
+
parsed.type === "text-delta" &&
|
|
1240
|
+
typeof parsed.delta === "string" &&
|
|
1241
|
+
parsed.delta.length > 0
|
|
1242
|
+
) {
|
|
1243
|
+
const deltaText = parsed.delta;
|
|
1244
|
+
|
|
1245
|
+
if (!hasReceivedFirstTextDelta) {
|
|
1246
|
+
hasReceivedFirstTextDelta = true;
|
|
1247
|
+
setIsAwaitingFirstToken(false);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
setMessages((previous) => {
|
|
1251
|
+
const existingAssistantMessage = previous.find(
|
|
1252
|
+
(message) => message.id === assistantId,
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
if (!existingAssistantMessage) {
|
|
1256
|
+
return [
|
|
1257
|
+
...previous,
|
|
1258
|
+
{
|
|
1259
|
+
id: assistantId,
|
|
1260
|
+
role: "assistant",
|
|
1261
|
+
content: deltaText,
|
|
1262
|
+
},
|
|
1263
|
+
];
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
return previous.map((message) =>
|
|
1267
|
+
message.id === assistantId
|
|
1268
|
+
? {
|
|
1269
|
+
...message,
|
|
1270
|
+
content: `${message.content}${deltaText}`,
|
|
1271
|
+
}
|
|
1272
|
+
: message,
|
|
1273
|
+
);
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
if (abortController.signal.aborted) {
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const resolvedError =
|
|
1284
|
+
error instanceof Error ? error.message : String(error);
|
|
1285
|
+
setErrorMessage(resolvedError || "Failed to get response.");
|
|
1286
|
+
} finally {
|
|
1287
|
+
if (activeRequestAbortRef.current === abortController) {
|
|
1288
|
+
activeRequestAbortRef.current = null;
|
|
1289
|
+
}
|
|
1290
|
+
setIsBusy(false);
|
|
1291
|
+
setIsAwaitingFirstToken(false);
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
const handleStartNewChat = () => {
|
|
1296
|
+
activeRequestAbortRef.current?.abort();
|
|
1297
|
+
activeRequestAbortRef.current = null;
|
|
1298
|
+
setIsBusy(false);
|
|
1299
|
+
setIsAwaitingFirstToken(false);
|
|
1300
|
+
setErrorMessage("");
|
|
1301
|
+
setInput("");
|
|
1302
|
+
setMessages([]);
|
|
1303
|
+
setThreadScrollTop(0);
|
|
1304
|
+
threadScrollTopRef.current = 0;
|
|
1305
|
+
const viewport = desktopScrollViewportRef.current;
|
|
1306
|
+
if (viewport) {
|
|
1307
|
+
viewport.scrollTop = 0;
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
const handleChatInputKeyDown = (event: ChatInputKeyEvent) => {
|
|
1312
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing) {
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
event.preventDefault();
|
|
1317
|
+
const form = event.currentTarget.form;
|
|
1318
|
+
if (form) {
|
|
1319
|
+
form.requestSubmit();
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
const handleRenderedMarkdownClick = (
|
|
1324
|
+
event: JSX.TargetedMouseEvent<HTMLDivElement>,
|
|
1325
|
+
) => {
|
|
1326
|
+
const targetElement = event.target instanceof Element ? event.target : null;
|
|
1327
|
+
const copyButton = targetElement?.closest(
|
|
1328
|
+
".ask-ai-copy-code-button",
|
|
1329
|
+
) as HTMLButtonElement | null;
|
|
1330
|
+
|
|
1331
|
+
if (!copyButton) {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
event.preventDefault();
|
|
1336
|
+
event.stopPropagation();
|
|
1337
|
+
|
|
1338
|
+
if (copyButton.dataset.copying === "true") {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const preElement = copyButton.closest("pre");
|
|
1343
|
+
const codeElement = preElement?.querySelector("code");
|
|
1344
|
+
if (!codeElement) {
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const codeText = codeElement.textContent ?? "";
|
|
1349
|
+
copyButton.dataset.copying = "true";
|
|
1350
|
+
|
|
1351
|
+
void (async () => {
|
|
1352
|
+
const didCopy = await copyToClipboard(codeText);
|
|
1353
|
+
if (didCopy) {
|
|
1354
|
+
copyButton.dataset.copied = "true";
|
|
1355
|
+
} else {
|
|
1356
|
+
delete copyButton.dataset.copied;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
window.setTimeout(() => {
|
|
1360
|
+
delete copyButton.dataset.copied;
|
|
1361
|
+
delete copyButton.dataset.copying;
|
|
1362
|
+
}, 1200);
|
|
1363
|
+
})();
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
const renderPanelContent = (mobile: boolean) => (
|
|
1367
|
+
<>
|
|
1368
|
+
<div
|
|
1369
|
+
className={`flex items-center justify-between border-b border-border px-4 ${mobile ? "py-3" : "py-2"}`}
|
|
1370
|
+
>
|
|
1371
|
+
<button
|
|
1372
|
+
type="button"
|
|
1373
|
+
className="inline-flex items-center gap-1.5 rounded-md -ml-2 px-2 py-1 text-[13px] text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-white/10 transition cursor-pointer"
|
|
1374
|
+
onClick={handleStartNewChat}
|
|
1375
|
+
disabled={isBusy}
|
|
1376
|
+
>
|
|
1377
|
+
<Icon
|
|
1378
|
+
icon="lucide:messages-square"
|
|
1379
|
+
className="size-3.5 -ml-px"
|
|
1380
|
+
aria-hidden="true"
|
|
1381
|
+
/>
|
|
1382
|
+
New chat
|
|
1383
|
+
</button>
|
|
1384
|
+
<button
|
|
1385
|
+
type="button"
|
|
1386
|
+
className="inline-flex items-center justify-center gap-1 rounded-full -mr-2 -my-px size-8 text-[13px] text-neutral-500 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-white/10 transition cursor-pointer"
|
|
1387
|
+
onClick={() => handleOpenChange(false)}
|
|
1388
|
+
>
|
|
1389
|
+
<Icon icon="lucide:x" className="size-4.5" aria-hidden="true" />
|
|
1390
|
+
</button>
|
|
1391
|
+
</div>
|
|
1392
|
+
|
|
1393
|
+
{isChatAvailable ? (
|
|
1394
|
+
<>
|
|
1395
|
+
<div
|
|
1396
|
+
className={
|
|
1397
|
+
mobile
|
|
1398
|
+
? `flex-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 py-3 ${messages.length > 0 && "pb-60"} mb-10 space-y-6`
|
|
1399
|
+
: `flex-1 overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 py-3 ${messages.length > 0 && "pb-60"} mb-10 space-y-6`
|
|
1400
|
+
}
|
|
1401
|
+
ref={desktopScrollViewportRef}
|
|
1402
|
+
onScroll={handleThreadViewportScroll}
|
|
1403
|
+
>
|
|
1404
|
+
{messages.length === 0 ? (
|
|
1405
|
+
<div class="flex items-center justify-center h-full">
|
|
1406
|
+
<p className="text-3xl font-[450]">How can I help?</p>
|
|
1407
|
+
</div>
|
|
1408
|
+
) : null}
|
|
1409
|
+
|
|
1410
|
+
{messages.map((message) => {
|
|
1411
|
+
if (
|
|
1412
|
+
message.role === "assistant" &&
|
|
1413
|
+
message.content.length === 0
|
|
1414
|
+
) {
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
const isUser = message.role === "user";
|
|
1418
|
+
return (
|
|
1419
|
+
<div
|
|
1420
|
+
key={message.id}
|
|
1421
|
+
className={isUser ? "text-right" : "text-left"}
|
|
1422
|
+
>
|
|
1423
|
+
<div
|
|
1424
|
+
className={[
|
|
1425
|
+
"ask-ai-markdown prose-rules max-w-full min-w-0 wrap-break-word prose-pre:bg-neutral-50! prose-pre:shadow-xs prose-pre:text-neutral-700! prose-pre:border prose-pre:border-neutral-200 prose-pre:rounded-xl!",
|
|
1426
|
+
isUser
|
|
1427
|
+
? "inline-block px-3 py-2 rounded-3xl rounded-br-sm bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 *:text-left"
|
|
1428
|
+
: "block w-full bg-transparent text-neutral-900 dark:text-neutral-100",
|
|
1429
|
+
].join(" ")}
|
|
1430
|
+
onClick={handleRenderedMarkdownClick}
|
|
1431
|
+
dangerouslySetInnerHTML={{
|
|
1432
|
+
__html: renderMarkdownToHtml(message.content),
|
|
1433
|
+
}}
|
|
1434
|
+
></div>
|
|
1435
|
+
</div>
|
|
1436
|
+
);
|
|
1437
|
+
})}
|
|
1438
|
+
|
|
1439
|
+
{isBusy && isAwaitingFirstToken ? (
|
|
1440
|
+
<div className="text-left">
|
|
1441
|
+
<span
|
|
1442
|
+
className="inline-block text-sm text-transparent bg-clip-text bg-[linear-gradient(110deg,var(--color-neutral-400)_50%,var(--color-neutral-300)_60%,var(--color-neutral-400)_70%)] dark:bg-[linear-gradient(110deg,#a3a3a3_30%,#ffffff_45%,#a3a3a3_60%)] bg-[length:200%_100%]"
|
|
1443
|
+
style={{
|
|
1444
|
+
animation: "ask-ai-thinking-shimmer 3.4s linear infinite",
|
|
1445
|
+
}}
|
|
1446
|
+
>
|
|
1447
|
+
Thinking
|
|
1448
|
+
</span>
|
|
1449
|
+
</div>
|
|
1450
|
+
) : null}
|
|
1451
|
+
|
|
1452
|
+
{errorMessage ? (
|
|
1453
|
+
<p className="text-sm text-red-600 dark:text-red-300">
|
|
1454
|
+
{errorMessage}
|
|
1455
|
+
</p>
|
|
1456
|
+
) : null}
|
|
1457
|
+
</div>
|
|
1458
|
+
|
|
1459
|
+
<form
|
|
1460
|
+
className={
|
|
1461
|
+
mobile
|
|
1462
|
+
? "absolute z-10 bottom-0 inset-x-0 px-3 pb-3"
|
|
1463
|
+
: "absolute z-10 bottom-0 inset-x-0 px-3 pb-3"
|
|
1464
|
+
}
|
|
1465
|
+
onSubmit={(event) => {
|
|
1466
|
+
void handleSubmit(event);
|
|
1467
|
+
}}
|
|
1468
|
+
>
|
|
1469
|
+
<div className="bg-white flex gap-2 border border-border rounded-[24px]">
|
|
1470
|
+
<textarea
|
|
1471
|
+
ref={chatInputTextareaRef}
|
|
1472
|
+
value={input}
|
|
1473
|
+
onInput={(event) => {
|
|
1474
|
+
setInput((event.target as HTMLTextAreaElement).value);
|
|
1475
|
+
}}
|
|
1476
|
+
onKeyDown={handleChatInputKeyDown}
|
|
1477
|
+
placeholder="Ask a question..."
|
|
1478
|
+
className="my-auto min-w-0 flex-1 bg-transparent pl-4 py-3 text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-400 leading-5 resize-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
1479
|
+
disabled={isBusy}
|
|
1480
|
+
rows={1}
|
|
1481
|
+
/>
|
|
1482
|
+
<div class="p-1 pl-0 mt-auto">
|
|
1483
|
+
<button
|
|
1484
|
+
type="submit"
|
|
1485
|
+
className="flex items-center justify-center rounded-full bg-neutral-900 size-10 disabled:opacity-90 dark:bg-white dark:text-neutral-900 cursor-pointer"
|
|
1486
|
+
disabled={isBusy || input.trim().length === 0}
|
|
1487
|
+
>
|
|
1488
|
+
<Icon
|
|
1489
|
+
icon="lucide:arrow-up"
|
|
1490
|
+
className="size-5 invert dark:invert-0"
|
|
1491
|
+
aria-hidden="true"
|
|
1492
|
+
/>
|
|
1493
|
+
</button>
|
|
1494
|
+
</div>
|
|
1495
|
+
</div>
|
|
1496
|
+
</form>
|
|
1497
|
+
</>
|
|
1498
|
+
) : (
|
|
1499
|
+
<div className={mobile ? "flex-1 px-4 py-4" : "px-4 py-4"}>
|
|
1500
|
+
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
|
1501
|
+
{unavailableMessage}
|
|
1502
|
+
</p>
|
|
1503
|
+
</div>
|
|
1504
|
+
)}
|
|
1505
|
+
</>
|
|
1506
|
+
);
|
|
1507
|
+
|
|
1508
|
+
const desktopMaxHeight = getDesktopMaxHeightPx();
|
|
1509
|
+
const desktopPanelWidth = clamp(
|
|
1510
|
+
desktopSize.width,
|
|
1511
|
+
DESKTOP_MIN_WIDTH_PX,
|
|
1512
|
+
DESKTOP_MAX_WIDTH_PX,
|
|
1513
|
+
);
|
|
1514
|
+
const desktopPanelHeight = clamp(
|
|
1515
|
+
desktopSize.height,
|
|
1516
|
+
DESKTOP_MIN_HEIGHT_PX,
|
|
1517
|
+
desktopMaxHeight,
|
|
1518
|
+
);
|
|
1519
|
+
|
|
1520
|
+
return (
|
|
1521
|
+
<>
|
|
1522
|
+
<style>{`
|
|
1523
|
+
@keyframes ask-ai-thinking-shimmer {
|
|
1524
|
+
0% { background-position: 200% 0; }
|
|
1525
|
+
100% { background-position: -200% 0; }
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
.prose-rules pre[class*="language-"],
|
|
1529
|
+
.prose-rules code[class*="language-"] {
|
|
1530
|
+
font-family: var(--font-mono);
|
|
1531
|
+
font-size: 13px;
|
|
1532
|
+
line-height: inherit;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
.ask-ai-markdown pre {
|
|
1536
|
+
position: relative;
|
|
1537
|
+
width: 100%;
|
|
1538
|
+
max-width: 100%;
|
|
1539
|
+
min-width: 0;
|
|
1540
|
+
box-sizing: border-box;
|
|
1541
|
+
padding-top: 2.25rem;
|
|
1542
|
+
overflow-x: auto;
|
|
1543
|
+
overflow-y: hidden;
|
|
1544
|
+
white-space: pre;
|
|
1545
|
+
-webkit-overflow-scrolling: touch;
|
|
1546
|
+
scrollbar-width: thin;
|
|
1547
|
+
scrollbar-color: var(--color-neutral-300) var(--color-neutral-100);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
.ask-ai-markdown pre[data-language]::before {
|
|
1551
|
+
content: "";
|
|
1552
|
+
position: absolute;
|
|
1553
|
+
top: 0.52rem;
|
|
1554
|
+
left: 0.75rem;
|
|
1555
|
+
width: 16px;
|
|
1556
|
+
height: 16px;
|
|
1557
|
+
background-color: var(--color-neutral-500);
|
|
1558
|
+
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='m9.4 16.6-4.6-4.6 4.6-4.6L8 6l-6 6 6 6zM14.6 16.6 19.2 12l-4.6-4.6L16 6l6 6-6 6z'/%3E%3C/svg%3E");
|
|
1559
|
+
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='m9.4 16.6-4.6-4.6 4.6-4.6L8 6l-6 6 6 6zM14.6 16.6 19.2 12l-4.6-4.6L16 6l6 6-6 6z'/%3E%3C/svg%3E");
|
|
1560
|
+
-webkit-mask-repeat: no-repeat;
|
|
1561
|
+
mask-repeat: no-repeat;
|
|
1562
|
+
-webkit-mask-position: center;
|
|
1563
|
+
mask-position: center;
|
|
1564
|
+
-webkit-mask-size: contain;
|
|
1565
|
+
mask-size: contain;
|
|
1566
|
+
pointer-events: none;
|
|
1567
|
+
user-select: none;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
.ask-ai-markdown pre[data-language]::after {
|
|
1571
|
+
content: attr(data-language);
|
|
1572
|
+
position: absolute;
|
|
1573
|
+
top: 0.6rem;
|
|
1574
|
+
left: 2.2rem;
|
|
1575
|
+
font-family: var(--font-sans);
|
|
1576
|
+
font-size: 14px;
|
|
1577
|
+
line-height: 1;
|
|
1578
|
+
letter-spacing: 0.01em;
|
|
1579
|
+
color: var(--color-neutral-500);
|
|
1580
|
+
pointer-events: none;
|
|
1581
|
+
user-select: none;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
.ask-ai-markdown .ask-ai-copy-code-button {
|
|
1585
|
+
position: absolute;
|
|
1586
|
+
top: 0.3rem;
|
|
1587
|
+
right: 0.46rem;
|
|
1588
|
+
z-index: 2;
|
|
1589
|
+
display: inline-flex;
|
|
1590
|
+
align-items: center;
|
|
1591
|
+
justify-content: center;
|
|
1592
|
+
width: 1.75rem;
|
|
1593
|
+
height: 1.75rem;
|
|
1594
|
+
border: 0;
|
|
1595
|
+
background: color-mix(
|
|
1596
|
+
in oklab,
|
|
1597
|
+
var(--color-neutral-50) 80%,
|
|
1598
|
+
transparent
|
|
1599
|
+
);
|
|
1600
|
+
backdrop-filter: blur(4px);
|
|
1601
|
+
color: color-mix(
|
|
1602
|
+
in oklab,
|
|
1603
|
+
var(--color-neutral-500) 80%,
|
|
1604
|
+
transparent
|
|
1605
|
+
);
|
|
1606
|
+
border-radius: 8px;
|
|
1607
|
+
cursor: pointer;
|
|
1608
|
+
transition: background-color 120ms ease, color 120ms ease,
|
|
1609
|
+
transform 120ms ease;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
.ask-ai-markdown .ask-ai-copy-code-button:hover {
|
|
1613
|
+
background: var(--color-neutral-100);
|
|
1614
|
+
color: var(--color-neutral-700);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
.ask-ai-markdown .ask-ai-copy-code-button:active {
|
|
1618
|
+
transform: translateY(0.5px);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
.ask-ai-markdown .ask-ai-copy-code-button:focus-visible {
|
|
1622
|
+
outline: 2px solid var(--color-neutral-300);
|
|
1623
|
+
outline-offset: 1px;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
.ask-ai-markdown .ask-ai-copy-icon,
|
|
1627
|
+
.ask-ai-markdown .ask-ai-copy-check-icon {
|
|
1628
|
+
position: absolute;
|
|
1629
|
+
width: 14px;
|
|
1630
|
+
height: 14px;
|
|
1631
|
+
transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
1632
|
+
opacity 250ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
1633
|
+
will-change: transform, opacity;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
.ask-ai-markdown .ask-ai-copy-icon {
|
|
1637
|
+
opacity: 1;
|
|
1638
|
+
transform: scale(1) rotate(0deg);
|
|
1639
|
+
background-color: currentColor;
|
|
1640
|
+
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E");
|
|
1641
|
+
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E");
|
|
1642
|
+
-webkit-mask-repeat: no-repeat;
|
|
1643
|
+
mask-repeat: no-repeat;
|
|
1644
|
+
-webkit-mask-position: center;
|
|
1645
|
+
mask-position: center;
|
|
1646
|
+
-webkit-mask-size: contain;
|
|
1647
|
+
mask-size: contain;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
.ask-ai-markdown .ask-ai-copy-check-icon {
|
|
1651
|
+
opacity: 0;
|
|
1652
|
+
transform: scale(0.25) rotate(6deg);
|
|
1653
|
+
background-color: color-mix(
|
|
1654
|
+
in oklab,
|
|
1655
|
+
var(--color-green-700) 80%,
|
|
1656
|
+
transparent
|
|
1657
|
+
);
|
|
1658
|
+
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
|
|
1659
|
+
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
|
|
1660
|
+
-webkit-mask-repeat: no-repeat;
|
|
1661
|
+
mask-repeat: no-repeat;
|
|
1662
|
+
-webkit-mask-position: center;
|
|
1663
|
+
mask-position: center;
|
|
1664
|
+
-webkit-mask-size: contain;
|
|
1665
|
+
mask-size: contain;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
.ask-ai-markdown .ask-ai-copy-code-button[data-copied="true"] .ask-ai-copy-icon {
|
|
1669
|
+
opacity: 0;
|
|
1670
|
+
transform: scale(0.5) rotate(-6deg);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
.ask-ai-markdown .ask-ai-copy-code-button[data-copied="true"] .ask-ai-copy-check-icon {
|
|
1674
|
+
opacity: 1;
|
|
1675
|
+
transform: scale(1.1) rotate(0deg);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
.ask-ai-markdown pre > code {
|
|
1679
|
+
display: block;
|
|
1680
|
+
min-width: max-content;
|
|
1681
|
+
white-space: inherit;
|
|
1682
|
+
word-break: normal;
|
|
1683
|
+
overflow-wrap: normal;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
.ask-ai-markdown pre::-webkit-scrollbar {
|
|
1687
|
+
height: 10px;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
.ask-ai-markdown pre::-webkit-scrollbar-track {
|
|
1691
|
+
background: var(--color-neutral-100);
|
|
1692
|
+
border-radius: 999px;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
.ask-ai-markdown pre::-webkit-scrollbar-thumb {
|
|
1696
|
+
background: var(--color-neutral-300);
|
|
1697
|
+
border-radius: 999px;
|
|
1698
|
+
border: 2px solid var(--color-neutral-100);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
.ask-ai-markdown pre::-webkit-scrollbar-thumb:hover {
|
|
1702
|
+
background: var(--color-neutral-400);
|
|
1703
|
+
}
|
|
1704
|
+
`}</style>
|
|
1705
|
+
{!isOpen ? (
|
|
1706
|
+
<div className="fixed bottom-5 right-6 size-12 z-40 bg-white hover:scale-105 transition duration-300 ease-in-out">
|
|
1707
|
+
<button
|
|
1708
|
+
type="button"
|
|
1709
|
+
className="w-full h-full inline-flex items-center justify-center gap-2 rounded-full bg-linear-to-b from-neutral-900/85 to-neutral-900 shadow-xl dark:bg-white cursor-pointer"
|
|
1710
|
+
onClick={() => handleOpenChange(true)}
|
|
1711
|
+
data-pagefind-ignore
|
|
1712
|
+
>
|
|
1713
|
+
<img
|
|
1714
|
+
src={sparkleIcon}
|
|
1715
|
+
alt=""
|
|
1716
|
+
aria-hidden="true"
|
|
1717
|
+
className="size-4 invert dark:invert-0"
|
|
1718
|
+
/>
|
|
1719
|
+
</button>
|
|
1720
|
+
</div>
|
|
1721
|
+
) : null}
|
|
1722
|
+
|
|
1723
|
+
{isMobile ? (
|
|
1724
|
+
<Drawer.Root
|
|
1725
|
+
open={isOpen}
|
|
1726
|
+
onOpenChange={handleOpenChange}
|
|
1727
|
+
disablePreventScroll={true}
|
|
1728
|
+
>
|
|
1729
|
+
<Drawer.Portal>
|
|
1730
|
+
<Drawer.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
|
1731
|
+
<Drawer.Content
|
|
1732
|
+
className="fixed inset-x-0 bottom-0 z-50 flex h-[85dvh] max-h-[85dvh] flex-col rounded-t-2xl border border-border bg-background shadow-xl will-change-transform"
|
|
1733
|
+
data-pagefind-ignore
|
|
1734
|
+
>
|
|
1735
|
+
<div className="absolute left-1/2 -translate-x-1/2 mt-2 h-1.5 w-12 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
|
1736
|
+
{renderPanelContent(true)}
|
|
1737
|
+
</Drawer.Content>
|
|
1738
|
+
</Drawer.Portal>
|
|
1739
|
+
</Drawer.Root>
|
|
1740
|
+
) : isOpen ? (
|
|
1741
|
+
<div
|
|
1742
|
+
className="fixed bottom-4 right-5 z-50"
|
|
1743
|
+
data-pagefind-ignore
|
|
1744
|
+
onWheelCapture={handleDesktopWheelCapture}
|
|
1745
|
+
>
|
|
1746
|
+
<div
|
|
1747
|
+
className="relative flex flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-[0_6px_20px_rgba(0,0,0,0.2)] shadow-neutral-300. shadow-black/15"
|
|
1748
|
+
style={{
|
|
1749
|
+
width: `${desktopPanelWidth}px`,
|
|
1750
|
+
height: `${desktopPanelHeight}px`,
|
|
1751
|
+
}}
|
|
1752
|
+
>
|
|
1753
|
+
<div
|
|
1754
|
+
className="absolute left-0 top-3 bottom-3 z-20 w-2 cursor-ew-resize touch-none"
|
|
1755
|
+
onPointerDown={beginDesktopResize("left")}
|
|
1756
|
+
/>
|
|
1757
|
+
<div
|
|
1758
|
+
className="absolute left-3 right-3 top-0 z-20 h-2 cursor-ns-resize touch-none"
|
|
1759
|
+
onPointerDown={beginDesktopResize("top")}
|
|
1760
|
+
/>
|
|
1761
|
+
<div
|
|
1762
|
+
className="absolute left-0 top-0 z-30 h-3 w-3 cursor-nwse-resize touch-none"
|
|
1763
|
+
onPointerDown={beginDesktopResize("corner")}
|
|
1764
|
+
/>
|
|
1765
|
+
{renderPanelContent(false)}
|
|
1766
|
+
</div>
|
|
1767
|
+
</div>
|
|
1768
|
+
) : null}
|
|
1769
|
+
</>
|
|
1770
|
+
);
|
|
1771
|
+
}
|