@hevmind/ask 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/bin/ask-launcher.mjs +110 -0
- package/bin/ask.mjs +4 -0
- package/openapi.yaml +363 -0
- package/package.json +61 -0
- package/skills/build-digest/SKILL.md +164 -0
- package/src/components/SearchOverlay.astro +1375 -0
- package/src/components/markdown.ts +107 -0
- package/src/digest/build.ts +432 -0
- package/src/digest/cli.ts +148 -0
- package/src/digest/expand.ts +24 -0
- package/src/digest/facts.ts +77 -0
- package/src/digest/frontmatter.ts +41 -0
- package/src/digest/read.ts +63 -0
- package/src/digest/schema.ts +185 -0
- package/src/digest/verify.ts +116 -0
- package/src/endpoint.ts +247 -0
- package/src/index.ts +2 -0
- package/src/integration.ts +146 -0
- package/src/llm.ts +239 -0
- package/src/observability.ts +213 -0
- package/src/search/chunk.ts +137 -0
- package/src/search/index.ts +44 -0
- package/src/search/loop.ts +525 -0
- package/src/search/prefilter.ts +93 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Endpoint to query. Must match the integration's `endpoint` option. */
|
|
4
|
+
endpoint?: string;
|
|
5
|
+
/** Placeholder text for the search input. */
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
/** Debounce in ms before a query is sent after typing stops. */
|
|
8
|
+
debounce?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
endpoint = '/api/ask',
|
|
13
|
+
placeholder = 'Search the docs…',
|
|
14
|
+
debounce = 500,
|
|
15
|
+
} = Astro.props;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<dialog
|
|
19
|
+
class="as-overlay"
|
|
20
|
+
data-endpoint={endpoint}
|
|
21
|
+
data-debounce={debounce}
|
|
22
|
+
aria-label="Search"
|
|
23
|
+
>
|
|
24
|
+
<div class="as-box">
|
|
25
|
+
<div class="as-inputrow">
|
|
26
|
+
<svg class="as-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
27
|
+
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
|
|
28
|
+
<path d="m20 20-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
29
|
+
</svg>
|
|
30
|
+
<input
|
|
31
|
+
class="as-input"
|
|
32
|
+
type="search"
|
|
33
|
+
placeholder={placeholder}
|
|
34
|
+
autocomplete="off"
|
|
35
|
+
autocorrect="off"
|
|
36
|
+
autocapitalize="off"
|
|
37
|
+
spellcheck="false"
|
|
38
|
+
aria-label="Search query"
|
|
39
|
+
/>
|
|
40
|
+
<button class="as-ai-button" type="button" disabled>
|
|
41
|
+
<span class="as-ai-label">Ask AI</span>
|
|
42
|
+
<kbd class="as-kbd">↵</kbd>
|
|
43
|
+
</button>
|
|
44
|
+
<kbd class="as-kbd as-esc">esc</kbd>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="as-results" role="listbox" aria-label="Search results" tabindex="-1"></div>
|
|
48
|
+
<div class="as-answer" hidden>
|
|
49
|
+
<div class="as-answer-body" aria-live="polite"></div>
|
|
50
|
+
<div class="as-answer-sources" hidden></div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="as-searched" hidden></div>
|
|
53
|
+
|
|
54
|
+
<div class="as-footer">
|
|
55
|
+
<span class="as-hint">
|
|
56
|
+
<kbd class="as-kbd">↵</kbd> ask AI · <kbd class="as-kbd">↑</kbd><kbd class="as-kbd">↓</kbd> open
|
|
57
|
+
</span>
|
|
58
|
+
<span class="as-meta">
|
|
59
|
+
<span class="as-mode" hidden>
|
|
60
|
+
<span class="as-mode-label"></span>
|
|
61
|
+
<button class="as-mode-toggle" type="button"></button>
|
|
62
|
+
</span>
|
|
63
|
+
<span class="as-brand"><a
|
|
64
|
+
class="as-brandlink"
|
|
65
|
+
href="https://github.com/hev/ask"
|
|
66
|
+
target="_blank"
|
|
67
|
+
rel="noopener noreferrer">hev/ask</a></span>
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</dialog>
|
|
72
|
+
|
|
73
|
+
<style>
|
|
74
|
+
.as-overlay {
|
|
75
|
+
--as-bg: var(--paper, #111111);
|
|
76
|
+
--as-fg: var(--ink, #f2f2f2);
|
|
77
|
+
--as-muted: var(--muted, #8a8a8a);
|
|
78
|
+
--as-accent: var(--signal, #e25822);
|
|
79
|
+
--as-border: color-mix(in srgb, var(--as-fg) 14%, transparent);
|
|
80
|
+
--as-hover: color-mix(in srgb, var(--as-fg) 7%, transparent);
|
|
81
|
+
|
|
82
|
+
width: min(640px, calc(100vw - 2rem));
|
|
83
|
+
max-width: 640px;
|
|
84
|
+
padding: 0;
|
|
85
|
+
border: 1px solid var(--as-border);
|
|
86
|
+
border-radius: 14px;
|
|
87
|
+
background: var(--as-bg);
|
|
88
|
+
color: var(--as-fg);
|
|
89
|
+
box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.6);
|
|
90
|
+
/* Explicit margins: a host site's `* { margin: 0 }` reset would otherwise
|
|
91
|
+
clobber the UA dialog centering and drift the overlay to the left. */
|
|
92
|
+
margin-block: 12vh auto;
|
|
93
|
+
margin-inline: auto;
|
|
94
|
+
overflow: hidden;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.as-overlay::backdrop {
|
|
98
|
+
background: rgba(0, 0, 0, 0.55);
|
|
99
|
+
backdrop-filter: blur(3px);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.as-overlay[open] {
|
|
103
|
+
animation: as-pop 120ms ease-out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@keyframes as-pop {
|
|
107
|
+
from { opacity: 0; transform: translateY(-6px); }
|
|
108
|
+
to { opacity: 1; transform: translateY(0); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.as-box {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.as-inputrow {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: 10px;
|
|
120
|
+
padding: 14px 16px;
|
|
121
|
+
border-bottom: 1px solid var(--as-border);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.as-icon {
|
|
125
|
+
color: var(--as-muted);
|
|
126
|
+
flex: none;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.as-input {
|
|
130
|
+
flex: 1;
|
|
131
|
+
min-width: 0;
|
|
132
|
+
border: none;
|
|
133
|
+
outline: none;
|
|
134
|
+
background: transparent;
|
|
135
|
+
color: var(--as-fg);
|
|
136
|
+
font-size: 1rem;
|
|
137
|
+
font-family: inherit;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.as-input::placeholder { color: var(--as-muted); }
|
|
141
|
+
/* Hide native search clear button for a cleaner palette. */
|
|
142
|
+
.as-input::-webkit-search-cancel-button { display: none; }
|
|
143
|
+
|
|
144
|
+
.as-ai-button {
|
|
145
|
+
flex: none;
|
|
146
|
+
display: inline-flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 7px;
|
|
149
|
+
min-height: 32px;
|
|
150
|
+
padding: 4px 8px 4px 10px;
|
|
151
|
+
border: 1px solid color-mix(in srgb, var(--as-accent) 70%, var(--as-border));
|
|
152
|
+
border-radius: 8px;
|
|
153
|
+
background: color-mix(in srgb, var(--as-accent) 18%, transparent);
|
|
154
|
+
color: var(--as-fg);
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
font-size: 0.78rem;
|
|
157
|
+
font-weight: 600;
|
|
158
|
+
transition: background 0.12s ease, border-color 0.12s ease, opacity 0.12s ease;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.as-ai-button:hover:not(:disabled) {
|
|
162
|
+
border-color: var(--as-accent);
|
|
163
|
+
background: color-mix(in srgb, var(--as-accent) 26%, transparent);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.as-ai-button:disabled {
|
|
167
|
+
cursor: default;
|
|
168
|
+
opacity: 0.48;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.as-ai-button--loading {
|
|
172
|
+
opacity: 0.78;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.as-ai-button .as-kbd {
|
|
176
|
+
color: var(--as-fg);
|
|
177
|
+
border-color: color-mix(in srgb, var(--as-accent) 55%, var(--as-border));
|
|
178
|
+
background: color-mix(in srgb, var(--as-accent) 20%, transparent);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.as-kbd {
|
|
182
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
183
|
+
font-size: 0.7rem;
|
|
184
|
+
line-height: 1;
|
|
185
|
+
padding: 3px 6px;
|
|
186
|
+
border: 1px solid var(--as-border);
|
|
187
|
+
border-radius: 5px;
|
|
188
|
+
color: var(--as-muted);
|
|
189
|
+
background: var(--as-hover);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.as-hint :global(.as-kbd) {
|
|
193
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
194
|
+
font-size: 0.7rem;
|
|
195
|
+
line-height: 1;
|
|
196
|
+
padding: 3px 6px;
|
|
197
|
+
border: 1px solid var(--as-border);
|
|
198
|
+
border-radius: 5px;
|
|
199
|
+
color: var(--as-muted);
|
|
200
|
+
background: var(--as-hover);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.as-results {
|
|
204
|
+
max-height: min(56vh, 440px);
|
|
205
|
+
overflow-y: auto;
|
|
206
|
+
padding: 6px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.as-results:empty { display: none; }
|
|
210
|
+
|
|
211
|
+
/* Result rows are created at runtime in client JS, so they never receive
|
|
212
|
+
Astro's scope attribute. Use :global() (anchored under the scoped
|
|
213
|
+
.as-results) so these styles actually apply to them. */
|
|
214
|
+
.as-results :global(.as-result) {
|
|
215
|
+
display: block;
|
|
216
|
+
padding: 9px 11px;
|
|
217
|
+
border-radius: 8px;
|
|
218
|
+
border-left: 2px solid transparent;
|
|
219
|
+
text-decoration: none;
|
|
220
|
+
color: var(--as-fg);
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.as-results :global(.as-result:hover) {
|
|
225
|
+
background: var(--as-hover);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.as-results :global(.as-result[aria-selected="true"]) {
|
|
229
|
+
background: color-mix(in srgb, var(--as-fg) 10%, transparent);
|
|
230
|
+
border-left-color: var(--as-accent);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.as-results :global(.as-result[aria-selected="true"] .as-result-title) {
|
|
234
|
+
color: var(--as-accent);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.as-results :global(.as-result-top) {
|
|
238
|
+
display: flex;
|
|
239
|
+
align-items: baseline;
|
|
240
|
+
justify-content: space-between;
|
|
241
|
+
gap: 12px;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.as-results :global(.as-result-title) {
|
|
245
|
+
font-weight: 600;
|
|
246
|
+
font-size: 0.92rem;
|
|
247
|
+
color: var(--as-fg);
|
|
248
|
+
text-decoration: none;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.as-results :global(.as-result-group) {
|
|
252
|
+
flex: none;
|
|
253
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
254
|
+
font-size: 0.66rem;
|
|
255
|
+
text-transform: uppercase;
|
|
256
|
+
letter-spacing: 0.04em;
|
|
257
|
+
color: var(--as-muted);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.as-results :global(.as-result-snippet) {
|
|
261
|
+
margin-top: 2px;
|
|
262
|
+
font-size: 0.82rem;
|
|
263
|
+
line-height: 1.45;
|
|
264
|
+
color: var(--as-muted);
|
|
265
|
+
text-decoration: none;
|
|
266
|
+
display: -webkit-box;
|
|
267
|
+
-webkit-line-clamp: 2;
|
|
268
|
+
-webkit-box-orient: vertical;
|
|
269
|
+
overflow: hidden;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.as-results :global(.as-result-heading) {
|
|
273
|
+
margin-top: 1px;
|
|
274
|
+
font-size: 0.72rem;
|
|
275
|
+
color: var(--as-muted);
|
|
276
|
+
opacity: 0.86;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.as-results :global(.as-state) {
|
|
280
|
+
padding: 22px 16px;
|
|
281
|
+
text-align: center;
|
|
282
|
+
color: var(--as-muted);
|
|
283
|
+
font-size: 0.88rem;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.as-results :global(.as-state.as-error) {
|
|
287
|
+
color: var(--as-accent);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* Empty-state suggested questions. */
|
|
291
|
+
.as-results :global(.as-suggest-label) {
|
|
292
|
+
padding: 8px 11px 4px;
|
|
293
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
294
|
+
font-size: 0.66rem;
|
|
295
|
+
text-transform: uppercase;
|
|
296
|
+
letter-spacing: 0.06em;
|
|
297
|
+
color: var(--as-muted);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.as-results :global(.as-suggest) {
|
|
301
|
+
display: block;
|
|
302
|
+
width: 100%;
|
|
303
|
+
text-align: left;
|
|
304
|
+
padding: 9px 11px;
|
|
305
|
+
border: none;
|
|
306
|
+
border-left: 2px solid transparent;
|
|
307
|
+
border-radius: 8px;
|
|
308
|
+
background: transparent;
|
|
309
|
+
color: var(--as-fg);
|
|
310
|
+
font: inherit;
|
|
311
|
+
font-size: 0.9rem;
|
|
312
|
+
cursor: pointer;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.as-results :global(.as-suggest:hover),
|
|
316
|
+
.as-results :global(.as-suggest:focus-visible) {
|
|
317
|
+
background: var(--as-hover);
|
|
318
|
+
border-left-color: var(--as-accent);
|
|
319
|
+
outline: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* Ask-mode hint row: clicking it (or pressing Enter) runs the agentic ask.
|
|
323
|
+
Deliberately calm — a quiet prompt, not an alarming orange button. */
|
|
324
|
+
.as-results :global(.as-ask-row) {
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
justify-content: space-between;
|
|
328
|
+
gap: 10px;
|
|
329
|
+
width: 100%;
|
|
330
|
+
text-align: left;
|
|
331
|
+
padding: 11px 12px;
|
|
332
|
+
border: 1px solid var(--as-border);
|
|
333
|
+
border-radius: 8px;
|
|
334
|
+
background: var(--as-hover);
|
|
335
|
+
color: var(--as-muted);
|
|
336
|
+
font: inherit;
|
|
337
|
+
cursor: pointer;
|
|
338
|
+
transition: border-color 0.12s ease, color 0.12s ease;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.as-results :global(.as-ask-row:hover) {
|
|
342
|
+
border-color: color-mix(in srgb, var(--as-accent) 45%, var(--as-border));
|
|
343
|
+
color: var(--as-fg);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.as-results :global(.as-ask-row-hint) {
|
|
347
|
+
flex: 1;
|
|
348
|
+
min-width: 0;
|
|
349
|
+
font-size: 0.88rem;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.as-results :global(.as-ask-row .as-kbd) {
|
|
353
|
+
flex: none;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.as-answer {
|
|
357
|
+
max-height: min(56vh, 440px);
|
|
358
|
+
overflow-y: auto;
|
|
359
|
+
padding: 14px 16px 6px;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.as-answer[hidden] { display: none; }
|
|
363
|
+
|
|
364
|
+
.as-answer-body {
|
|
365
|
+
font-size: 0.9rem;
|
|
366
|
+
line-height: 1.55;
|
|
367
|
+
color: var(--as-fg);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.as-answer-body :global(p) {
|
|
371
|
+
margin: 0 0 0.7em;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.as-answer-body :global(p:last-child) {
|
|
375
|
+
margin-bottom: 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.as-answer-body :global(ul),
|
|
379
|
+
.as-answer-body :global(ol) {
|
|
380
|
+
margin: 0 0 0.7em;
|
|
381
|
+
padding-left: 1.3em;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.as-answer-body :global(li) {
|
|
385
|
+
margin: 0.15em 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.as-answer-body :global(code) {
|
|
389
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
390
|
+
font-size: 0.82em;
|
|
391
|
+
padding: 1px 5px;
|
|
392
|
+
border-radius: 5px;
|
|
393
|
+
background: var(--as-hover);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* Animated tool-calling status (spinner + cycling verb). Built at runtime, so
|
|
397
|
+
these live under :global() anchored to the scoped .as-answer-body. */
|
|
398
|
+
.as-answer-body :global(.as-thinking) {
|
|
399
|
+
display: inline-flex;
|
|
400
|
+
align-items: baseline;
|
|
401
|
+
gap: 9px;
|
|
402
|
+
color: var(--as-muted);
|
|
403
|
+
font-size: 0.9rem;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.as-answer-body :global(.as-thinking-spin) {
|
|
407
|
+
display: inline-block;
|
|
408
|
+
width: 1ch;
|
|
409
|
+
color: var(--as-accent);
|
|
410
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
411
|
+
font-size: 1.05rem;
|
|
412
|
+
line-height: 1;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.as-answer-body :global(.as-thinking-verb) {
|
|
416
|
+
font-variant-numeric: tabular-nums;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.as-answer-body :global(.as-answer-link) {
|
|
420
|
+
color: var(--as-accent);
|
|
421
|
+
text-decoration: none;
|
|
422
|
+
border-bottom: 1px solid color-mix(in srgb, var(--as-accent) 45%, transparent);
|
|
423
|
+
transition: border-color 0.12s ease;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.as-answer-body :global(.as-answer-link:hover) {
|
|
427
|
+
border-bottom-color: var(--as-accent);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* Streaming caret while tokens are still arriving. */
|
|
431
|
+
.as-answer-body.is-streaming::after {
|
|
432
|
+
content: "▌";
|
|
433
|
+
margin-left: 1px;
|
|
434
|
+
color: var(--as-accent);
|
|
435
|
+
animation: as-blink 1s step-end infinite;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
@keyframes as-blink {
|
|
439
|
+
50% { opacity: 0; }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.as-answer-sources {
|
|
443
|
+
display: flex;
|
|
444
|
+
flex-wrap: wrap;
|
|
445
|
+
align-items: center;
|
|
446
|
+
gap: 6px;
|
|
447
|
+
margin-top: 12px;
|
|
448
|
+
padding-top: 10px;
|
|
449
|
+
border-top: 1px solid var(--as-border);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.as-answer-sources[hidden] { display: none; }
|
|
453
|
+
|
|
454
|
+
.as-answer-sources :global(.as-answer-sources-label) {
|
|
455
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
456
|
+
font-size: 0.66rem;
|
|
457
|
+
text-transform: uppercase;
|
|
458
|
+
letter-spacing: 0.04em;
|
|
459
|
+
color: var(--as-muted);
|
|
460
|
+
margin-right: 2px;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.as-answer-sources :global(.as-source-chip) {
|
|
464
|
+
font-size: 0.74rem;
|
|
465
|
+
padding: 3px 8px;
|
|
466
|
+
border: 1px solid var(--as-border);
|
|
467
|
+
border-radius: 6px;
|
|
468
|
+
color: var(--as-fg);
|
|
469
|
+
text-decoration: none;
|
|
470
|
+
transition: border-color 0.12s ease, color 0.12s ease;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.as-answer-sources :global(.as-source-chip:hover) {
|
|
474
|
+
border-color: var(--as-accent);
|
|
475
|
+
color: var(--as-accent);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.as-searched {
|
|
479
|
+
padding: 0 16px 9px;
|
|
480
|
+
color: var(--as-muted);
|
|
481
|
+
font-size: 0.72rem;
|
|
482
|
+
opacity: 0.72;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.as-searched.as-warning {
|
|
486
|
+
color: var(--as-accent);
|
|
487
|
+
opacity: 1;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.as-footer {
|
|
491
|
+
display: flex;
|
|
492
|
+
align-items: center;
|
|
493
|
+
justify-content: space-between;
|
|
494
|
+
padding: 10px 16px;
|
|
495
|
+
border-top: 1px solid var(--as-border);
|
|
496
|
+
font-size: 0.75rem;
|
|
497
|
+
color: var(--as-muted);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.as-hint { display: flex; align-items: center; gap: 5px; }
|
|
501
|
+
|
|
502
|
+
.as-meta {
|
|
503
|
+
display: inline-flex;
|
|
504
|
+
align-items: center;
|
|
505
|
+
gap: 8px;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.as-mode {
|
|
509
|
+
display: inline-flex;
|
|
510
|
+
align-items: center;
|
|
511
|
+
gap: 6px;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.as-mode:not([hidden]) + .as-brand::before {
|
|
515
|
+
content: "·";
|
|
516
|
+
margin-right: 8px;
|
|
517
|
+
opacity: 0.5;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.as-mode-label {
|
|
521
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
522
|
+
font-size: 0.7rem;
|
|
523
|
+
color: var(--as-muted);
|
|
524
|
+
opacity: 0.75;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.as-mode--off .as-mode-label {
|
|
528
|
+
text-decoration: line-through;
|
|
529
|
+
opacity: 0.5;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.as-mode-toggle {
|
|
533
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
534
|
+
font-size: 0.7rem;
|
|
535
|
+
line-height: 1;
|
|
536
|
+
padding: 2px 5px;
|
|
537
|
+
border: 1px solid var(--as-border);
|
|
538
|
+
border-radius: 5px;
|
|
539
|
+
background: transparent;
|
|
540
|
+
color: var(--as-muted);
|
|
541
|
+
cursor: pointer;
|
|
542
|
+
transition: color 0.12s ease, border-color 0.12s ease;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.as-mode-toggle:hover {
|
|
546
|
+
color: var(--as-accent);
|
|
547
|
+
border-color: var(--as-accent);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.as-brand {
|
|
551
|
+
font-family: var(--font-mono, "JetBrains Mono Variable", ui-monospace, monospace);
|
|
552
|
+
letter-spacing: 0.04em;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.as-brandlink {
|
|
556
|
+
color: var(--signal, #e25822);
|
|
557
|
+
text-decoration: none;
|
|
558
|
+
font-weight: 600;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.as-brandlink:hover {
|
|
562
|
+
text-decoration: underline;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
@media (prefers-reduced-motion: reduce) {
|
|
566
|
+
.as-overlay[open] { animation: none; }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
@media (max-width: 520px) {
|
|
570
|
+
.as-ai-label {
|
|
571
|
+
display: none;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.as-ai-button {
|
|
575
|
+
padding-inline: 8px;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
</style>
|
|
579
|
+
|
|
580
|
+
<script>
|
|
581
|
+
import { renderMarkdown, sourceBreadcrumb, type Source } from './markdown.ts';
|
|
582
|
+
|
|
583
|
+
interface SearchResult {
|
|
584
|
+
title: string;
|
|
585
|
+
heading?: string;
|
|
586
|
+
url: string;
|
|
587
|
+
group?: string;
|
|
588
|
+
snippet: string;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
interface SearchResponse {
|
|
592
|
+
results?: SearchResult[];
|
|
593
|
+
searches?: string[];
|
|
594
|
+
model?: string;
|
|
595
|
+
mode?: 'keyword' | 'agentic';
|
|
596
|
+
warning?: string;
|
|
597
|
+
error?: string;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Braille spinner — reads as a smooth rotating glyph in monospace.
|
|
601
|
+
const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
602
|
+
// Whimsical gerunds, cycled while the agent is tool-calling (à la Claude Code).
|
|
603
|
+
const THINKING_VERBS = [
|
|
604
|
+
'Sautéing', 'Percolating', 'Noodling', 'Marinating', 'Conjuring', 'Rummaging',
|
|
605
|
+
'Foraging', 'Spelunking', 'Distilling', 'Untangling', 'Divining', 'Pondering',
|
|
606
|
+
'Scheming', 'Brewing', 'Whisking', 'Simmering', 'Excavating', 'Sifting',
|
|
607
|
+
'Wrangling', 'Cogitating', 'Finagling', 'Tinkering', 'Riffing', 'Mulling',
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
class HevAskOverlay {
|
|
611
|
+
private dialog: HTMLDialogElement;
|
|
612
|
+
private input: HTMLInputElement;
|
|
613
|
+
private results: HTMLDivElement;
|
|
614
|
+
private answer: HTMLDivElement;
|
|
615
|
+
private answerBody: HTMLDivElement;
|
|
616
|
+
private answerSources: HTMLDivElement;
|
|
617
|
+
private searched: HTMLDivElement;
|
|
618
|
+
private askButton: HTMLButtonElement;
|
|
619
|
+
private askButtonLabel: HTMLElement;
|
|
620
|
+
private hint: HTMLElement;
|
|
621
|
+
private modeEl: HTMLElement | null;
|
|
622
|
+
private modeLabelEl: HTMLElement | null;
|
|
623
|
+
private modeToggleEl: HTMLButtonElement | null;
|
|
624
|
+
private model = '';
|
|
625
|
+
private agentic = true;
|
|
626
|
+
private endpoint: string;
|
|
627
|
+
private debounceMs: number;
|
|
628
|
+
private timer: number | undefined;
|
|
629
|
+
private controller: AbortController | undefined;
|
|
630
|
+
private activeMode: 'keyword' | 'agentic' | null = null;
|
|
631
|
+
private requestSeq = 0;
|
|
632
|
+
private items: HTMLAnchorElement[] = [];
|
|
633
|
+
private active = -1;
|
|
634
|
+
private lastQuery = '';
|
|
635
|
+
private userSelected = false;
|
|
636
|
+
private pressOnBackdrop = false;
|
|
637
|
+
private answerRaf: number | undefined;
|
|
638
|
+
private thinkTimer: number | undefined;
|
|
639
|
+
private suggestions: string[] = [];
|
|
640
|
+
private suggestionsFetched = false;
|
|
641
|
+
private defaultPlaceholder: string;
|
|
642
|
+
|
|
643
|
+
constructor(dialog: HTMLDialogElement) {
|
|
644
|
+
this.dialog = dialog;
|
|
645
|
+
this.endpoint = dialog.dataset.endpoint || '/api/ask';
|
|
646
|
+
this.debounceMs = Number(dialog.dataset.debounce) || 300;
|
|
647
|
+
this.input = dialog.querySelector('.as-input') as HTMLInputElement;
|
|
648
|
+
this.defaultPlaceholder = this.input.getAttribute('placeholder') || 'Search the docs…';
|
|
649
|
+
this.results = dialog.querySelector('.as-results') as HTMLDivElement;
|
|
650
|
+
this.answer = dialog.querySelector('.as-answer') as HTMLDivElement;
|
|
651
|
+
this.answerBody = dialog.querySelector('.as-answer-body') as HTMLDivElement;
|
|
652
|
+
this.answerSources = dialog.querySelector('.as-answer-sources') as HTMLDivElement;
|
|
653
|
+
this.searched = dialog.querySelector('.as-searched') as HTMLDivElement;
|
|
654
|
+
this.askButton = dialog.querySelector('.as-ai-button') as HTMLButtonElement;
|
|
655
|
+
this.askButtonLabel = dialog.querySelector('.as-ai-label') as HTMLElement;
|
|
656
|
+
this.hint = dialog.querySelector('.as-hint') as HTMLElement;
|
|
657
|
+
this.modeEl = dialog.querySelector('.as-mode');
|
|
658
|
+
this.modeLabelEl = dialog.querySelector('.as-mode-label');
|
|
659
|
+
this.modeToggleEl = dialog.querySelector('.as-mode-toggle');
|
|
660
|
+
this.agentic = this.readPref();
|
|
661
|
+
this.askButton.addEventListener('click', (e) => {
|
|
662
|
+
e.preventDefault();
|
|
663
|
+
e.stopPropagation();
|
|
664
|
+
this.askAgentic();
|
|
665
|
+
});
|
|
666
|
+
this.modeToggleEl?.addEventListener('click', () => this.toggleMode());
|
|
667
|
+
this.updateModeUI();
|
|
668
|
+
this.applyModePlaceholder();
|
|
669
|
+
this.syncAskButton();
|
|
670
|
+
|
|
671
|
+
this.input.addEventListener('input', () => this.onInput());
|
|
672
|
+
this.dialog.addEventListener('keydown', (e) => this.onKeydown(e));
|
|
673
|
+
this.dialog.addEventListener('close', () => this.reset());
|
|
674
|
+
// Clicking the backdrop (outside the inner box) closes the dialog. A
|
|
675
|
+
// `click` resolves to the common ancestor of its mousedown and mouseup
|
|
676
|
+
// targets, so a drag-select that starts in the input and releases on the
|
|
677
|
+
// backdrop would otherwise resolve to the dialog and close it. Require the
|
|
678
|
+
// press to have *started* on the backdrop too, so text selection and
|
|
679
|
+
// edge-sloppy clicks inside the box never dismiss the overlay.
|
|
680
|
+
this.dialog.addEventListener('mousedown', (e) => {
|
|
681
|
+
this.pressOnBackdrop = e.target === this.dialog;
|
|
682
|
+
});
|
|
683
|
+
this.dialog.addEventListener('click', (e) => {
|
|
684
|
+
if (e.target === this.dialog && this.pressOnBackdrop) this.dialog.close();
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
open() {
|
|
689
|
+
if (this.dialog.open) return;
|
|
690
|
+
this.dialog.showModal();
|
|
691
|
+
this.input.focus();
|
|
692
|
+
this.input.select();
|
|
693
|
+
void this.maybeFetchSuggestions();
|
|
694
|
+
this.showInitial();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The suggested questions come from the digest and are served by the
|
|
699
|
+
* endpoint's GET. Fetch once per session, only when AI is on.
|
|
700
|
+
*/
|
|
701
|
+
private async maybeFetchSuggestions() {
|
|
702
|
+
if (this.suggestionsFetched || !this.agentic) return;
|
|
703
|
+
this.suggestionsFetched = true;
|
|
704
|
+
try {
|
|
705
|
+
const res = await fetch(this.endpoint, { method: 'GET', headers: { accept: 'application/json' } });
|
|
706
|
+
if (!res.ok) return;
|
|
707
|
+
const data = (await res.json()) as { suggestions?: string[]; model?: string };
|
|
708
|
+
if (data.model) this.setModel(data.model);
|
|
709
|
+
this.suggestions = Array.isArray(data.suggestions) ? data.suggestions.filter(Boolean).slice(0, 4) : [];
|
|
710
|
+
// If the overlay is still open on an empty query, surface them now.
|
|
711
|
+
if (this.dialog.open && !this.input.value.trim()) this.showInitial();
|
|
712
|
+
} catch {
|
|
713
|
+
/* suggestions are a nicety; ignore fetch failures */
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Empty-state view: suggested questions when asking, otherwise nothing. */
|
|
718
|
+
private showInitial() {
|
|
719
|
+
this.clearAnswer();
|
|
720
|
+
if (this.agentic && this.suggestions.length) {
|
|
721
|
+
this.renderSuggestions();
|
|
722
|
+
} else {
|
|
723
|
+
this.render([]);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private reset() {
|
|
728
|
+
this.controller?.abort();
|
|
729
|
+
window.clearTimeout(this.timer);
|
|
730
|
+
this.timer = undefined;
|
|
731
|
+
this.activeMode = null;
|
|
732
|
+
this.requestSeq += 1;
|
|
733
|
+
this.input.value = '';
|
|
734
|
+
this.results.innerHTML = '';
|
|
735
|
+
this.clearAnswer();
|
|
736
|
+
this.searched.classList.remove('as-warning');
|
|
737
|
+
this.searched.hidden = true;
|
|
738
|
+
this.searched.textContent = '';
|
|
739
|
+
this.items = [];
|
|
740
|
+
this.active = -1;
|
|
741
|
+
this.lastQuery = '';
|
|
742
|
+
this.userSelected = false;
|
|
743
|
+
this.syncAskButton();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private onInput() {
|
|
747
|
+
window.clearTimeout(this.timer);
|
|
748
|
+
this.timer = undefined;
|
|
749
|
+
const q = this.input.value.trim();
|
|
750
|
+
if (this.activeMode === 'agentic') {
|
|
751
|
+
this.controller?.abort();
|
|
752
|
+
this.activeMode = null;
|
|
753
|
+
}
|
|
754
|
+
this.syncAskButton();
|
|
755
|
+
if (!q) {
|
|
756
|
+
this.controller?.abort();
|
|
757
|
+
this.showInitial();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// Ask-first: once the query is more than one word (a space appears) and AI
|
|
761
|
+
// is on, stop keyword type-ahead and switch to ask mode — Enter asks.
|
|
762
|
+
if (this.agentic && /\s/.test(q)) {
|
|
763
|
+
this.enterAskMode(q);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
this.timer = window.setTimeout(() => this.searchKeyword(q), this.debounceMs);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Multi-word query with AI on: present the ask affordance, don't call the model yet. */
|
|
770
|
+
private enterAskMode(query: string) {
|
|
771
|
+
window.clearTimeout(this.timer);
|
|
772
|
+
this.timer = undefined;
|
|
773
|
+
this.controller?.abort();
|
|
774
|
+
this.activeMode = null;
|
|
775
|
+
this.lastQuery = '';
|
|
776
|
+
this.clearAnswer();
|
|
777
|
+
this.renderAsk(query);
|
|
778
|
+
this.syncAskButton();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private async searchKeyword(query: string) {
|
|
782
|
+
if (this.activeMode === 'agentic') return;
|
|
783
|
+
if (query === this.lastQuery) return;
|
|
784
|
+
this.lastQuery = query;
|
|
785
|
+
await this.search(query, 'keyword');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private async runAgentic(query: string) {
|
|
789
|
+
window.clearTimeout(this.timer);
|
|
790
|
+
this.timer = undefined;
|
|
791
|
+
this.lastQuery = '';
|
|
792
|
+
await this.streamAgentic(query);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private askAgentic() {
|
|
796
|
+
const q = this.input.value.trim();
|
|
797
|
+
if (!q) return;
|
|
798
|
+
void this.runAgentic(q);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private async search(query: string, mode: 'keyword' | 'agentic') {
|
|
802
|
+
const requestId = this.requestSeq + 1;
|
|
803
|
+
this.requestSeq = requestId;
|
|
804
|
+
this.controller?.abort();
|
|
805
|
+
const controller = new AbortController();
|
|
806
|
+
this.controller = controller;
|
|
807
|
+
this.activeMode = mode;
|
|
808
|
+
this.syncAskButton();
|
|
809
|
+
this.searched.hidden = true;
|
|
810
|
+
this.searched.textContent = '';
|
|
811
|
+
this.searched.classList.remove('as-warning');
|
|
812
|
+
this.setState('Searching…');
|
|
813
|
+
const rankingTimer =
|
|
814
|
+
mode === 'agentic'
|
|
815
|
+
? window.setTimeout(() => {
|
|
816
|
+
if (this.requestSeq === requestId && this.activeMode === 'agentic') this.setState('Ranking…');
|
|
817
|
+
}, 700)
|
|
818
|
+
: undefined;
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
const res = await fetch(this.endpoint, {
|
|
822
|
+
method: 'POST',
|
|
823
|
+
headers: { 'content-type': 'application/json' },
|
|
824
|
+
body: JSON.stringify({ query, mode }),
|
|
825
|
+
signal: controller.signal,
|
|
826
|
+
});
|
|
827
|
+
if (rankingTimer) window.clearTimeout(rankingTimer);
|
|
828
|
+
const contentType = res.headers.get('content-type') || '';
|
|
829
|
+
if (!contentType.includes('application/json')) {
|
|
830
|
+
throw new Error(
|
|
831
|
+
res.status === 404
|
|
832
|
+
? 'Search endpoint not found — is the integration loaded? (Restart the dev server.)'
|
|
833
|
+
: `Unexpected response from search endpoint (${res.status}).`,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
const data = (await res.json()) as SearchResponse;
|
|
837
|
+
if (!res.ok) throw new Error(data.error || 'Search failed.');
|
|
838
|
+
if (data.model) this.setModel(data.model);
|
|
839
|
+
// Ignore stale responses if the query moved on.
|
|
840
|
+
if (this.requestSeq !== requestId || controller.signal.aborted) return;
|
|
841
|
+
if (this.input.value.trim() !== query) return;
|
|
842
|
+
if (!data.results?.length) {
|
|
843
|
+
this.setState('No results.');
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const warning =
|
|
847
|
+
data.warning ||
|
|
848
|
+
(mode === 'agentic' && data.mode !== 'agentic'
|
|
849
|
+
? 'AI search is unavailable; showing keyword matches.'
|
|
850
|
+
: '');
|
|
851
|
+
this.render(data.results, data.searches, warning);
|
|
852
|
+
} catch (err) {
|
|
853
|
+
if (rankingTimer) window.clearTimeout(rankingTimer);
|
|
854
|
+
if ((err as Error).name === 'AbortError') return;
|
|
855
|
+
this.setState((err as Error).message, true);
|
|
856
|
+
} finally {
|
|
857
|
+
if (this.requestSeq === requestId) {
|
|
858
|
+
this.activeMode = null;
|
|
859
|
+
this.syncAskButton();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
private async streamAgentic(query: string) {
|
|
865
|
+
const requestId = this.requestSeq + 1;
|
|
866
|
+
this.requestSeq = requestId;
|
|
867
|
+
this.controller?.abort();
|
|
868
|
+
const controller = new AbortController();
|
|
869
|
+
this.controller = controller;
|
|
870
|
+
this.activeMode = 'agentic';
|
|
871
|
+
this.syncAskButton();
|
|
872
|
+
this.results.innerHTML = '';
|
|
873
|
+
this.items = [];
|
|
874
|
+
this.active = -1;
|
|
875
|
+
this.userSelected = false;
|
|
876
|
+
this.searched.hidden = true;
|
|
877
|
+
this.searched.textContent = '';
|
|
878
|
+
this.searched.classList.remove('as-warning');
|
|
879
|
+
this.showAnswer();
|
|
880
|
+
this.startThinking();
|
|
881
|
+
|
|
882
|
+
const searches: string[] = [];
|
|
883
|
+
let sources: Source[] = [];
|
|
884
|
+
let answer = '';
|
|
885
|
+
const stale = () =>
|
|
886
|
+
this.requestSeq !== requestId || controller.signal.aborted || this.input.value.trim() !== query;
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
const res = await fetch(this.endpoint, {
|
|
890
|
+
method: 'POST',
|
|
891
|
+
headers: { 'content-type': 'application/json' },
|
|
892
|
+
body: JSON.stringify({ query, mode: 'agentic' }),
|
|
893
|
+
signal: controller.signal,
|
|
894
|
+
});
|
|
895
|
+
const contentType = res.headers.get('content-type') || '';
|
|
896
|
+
|
|
897
|
+
// Graceful degradation (no API key) returns JSON keyword results.
|
|
898
|
+
if (contentType.includes('application/json')) {
|
|
899
|
+
const data = (await res.json()) as SearchResponse;
|
|
900
|
+
if (!res.ok) throw new Error(data.error || 'Search failed.');
|
|
901
|
+
if (data.model) this.setModel(data.model);
|
|
902
|
+
if (stale()) return;
|
|
903
|
+
this.clearAnswer();
|
|
904
|
+
if (!data.results?.length) {
|
|
905
|
+
this.setState('No results.');
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
this.render(data.results, data.searches, data.warning || 'AI search is unavailable; showing keyword matches.');
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (!contentType.includes('text/event-stream') || !res.body) {
|
|
913
|
+
throw new Error(
|
|
914
|
+
res.status === 404
|
|
915
|
+
? 'Search endpoint not found — is the integration loaded? (Restart the dev server.)'
|
|
916
|
+
: `Unexpected response from search endpoint (${res.status}).`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const reader = res.body.getReader();
|
|
921
|
+
const decoder = new TextDecoder('utf-8');
|
|
922
|
+
let buffer = '';
|
|
923
|
+
let started = false;
|
|
924
|
+
|
|
925
|
+
while (true) {
|
|
926
|
+
const { done, value } = await reader.read();
|
|
927
|
+
if (done) break;
|
|
928
|
+
buffer += decoder.decode(value, { stream: true });
|
|
929
|
+
let sep: number;
|
|
930
|
+
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
|
931
|
+
const frame = buffer.slice(0, sep);
|
|
932
|
+
buffer = buffer.slice(sep + 2);
|
|
933
|
+
const evt = this.parseSseFrame(frame);
|
|
934
|
+
if (!evt) continue;
|
|
935
|
+
if (stale()) return;
|
|
936
|
+
|
|
937
|
+
if (evt.event === 'search') {
|
|
938
|
+
const q = (evt.data as { query?: string }).query;
|
|
939
|
+
if (q) {
|
|
940
|
+
searches.push(q);
|
|
941
|
+
this.showSearched(`searching: ${searches.join(', ')}`);
|
|
942
|
+
}
|
|
943
|
+
} else if (evt.event === 'sources') {
|
|
944
|
+
const payload = evt.data as { sources?: Source[]; model?: string };
|
|
945
|
+
sources = payload.sources || [];
|
|
946
|
+
if (payload.model) this.setModel(payload.model);
|
|
947
|
+
this.renderSources(sources);
|
|
948
|
+
} else if (evt.event === 'token') {
|
|
949
|
+
if (!started) {
|
|
950
|
+
started = true;
|
|
951
|
+
answer = '';
|
|
952
|
+
this.stopThinking();
|
|
953
|
+
this.answerBody.classList.add('is-streaming');
|
|
954
|
+
}
|
|
955
|
+
answer += (evt.data as { text?: string }).text || '';
|
|
956
|
+
this.scheduleAnswerRender(answer, sources);
|
|
957
|
+
} else if (evt.event === 'done') {
|
|
958
|
+
this.stopThinking();
|
|
959
|
+
this.flushAnswerRender(answer, sources);
|
|
960
|
+
this.answerBody.classList.remove('is-streaming');
|
|
961
|
+
if (searches.length) this.showSearched(`searched: ${searches.join(', ')}`);
|
|
962
|
+
} else if (evt.event === 'error') {
|
|
963
|
+
throw new Error((evt.data as { error?: string }).error || 'AI search failed.');
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
this.answerBody.classList.remove('is-streaming');
|
|
968
|
+
} catch (err) {
|
|
969
|
+
if ((err as Error).name === 'AbortError') return;
|
|
970
|
+
if (stale()) return;
|
|
971
|
+
this.clearAnswer();
|
|
972
|
+
this.setState((err as Error).message, true);
|
|
973
|
+
} finally {
|
|
974
|
+
if (this.requestSeq === requestId) {
|
|
975
|
+
this.activeMode = null;
|
|
976
|
+
this.syncAskButton();
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private parseSseFrame(frame: string): { event: string; data: unknown } | null {
|
|
982
|
+
let event = 'message';
|
|
983
|
+
const dataLines: string[] = [];
|
|
984
|
+
for (const line of frame.split('\n')) {
|
|
985
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
986
|
+
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
|
987
|
+
}
|
|
988
|
+
if (!dataLines.length) return null;
|
|
989
|
+
try {
|
|
990
|
+
return { event, data: JSON.parse(dataLines.join('')) };
|
|
991
|
+
} catch {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Animated "the agent is working" status: a braille spinner alongside a
|
|
998
|
+
* whimsical gerund that cycles every so often. Replaces the old static
|
|
999
|
+
* "Searching…" placeholder while the loop tool-calls, before tokens stream.
|
|
1000
|
+
*/
|
|
1001
|
+
private startThinking() {
|
|
1002
|
+
this.stopThinking();
|
|
1003
|
+
this.answerBody.classList.remove('is-streaming');
|
|
1004
|
+
this.answerBody.innerHTML =
|
|
1005
|
+
'<span class="as-thinking"><span class="as-thinking-spin"></span><span class="as-thinking-verb"></span></span>';
|
|
1006
|
+
const spin = this.answerBody.querySelector('.as-thinking-spin') as HTMLElement;
|
|
1007
|
+
const verb = this.answerBody.querySelector('.as-thinking-verb') as HTMLElement;
|
|
1008
|
+
// Start on a different verb each time so repeat asks feel alive.
|
|
1009
|
+
let vi = Math.floor(Math.random() * THINKING_VERBS.length);
|
|
1010
|
+
const setVerb = () => {
|
|
1011
|
+
verb.textContent = `${THINKING_VERBS[vi % THINKING_VERBS.length]}…`;
|
|
1012
|
+
};
|
|
1013
|
+
spin.textContent = SPIN_FRAMES[0];
|
|
1014
|
+
setVerb();
|
|
1015
|
+
const reduce =
|
|
1016
|
+
typeof window.matchMedia === 'function' &&
|
|
1017
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1018
|
+
if (reduce) return; // honour reduced-motion: static spinner + verb, no churn.
|
|
1019
|
+
let fi = 0;
|
|
1020
|
+
let tick = 0;
|
|
1021
|
+
this.thinkTimer = window.setInterval(() => {
|
|
1022
|
+
fi = (fi + 1) % SPIN_FRAMES.length;
|
|
1023
|
+
spin.textContent = SPIN_FRAMES[fi];
|
|
1024
|
+
// ~18 frames × 90ms ≈ 1.6s per verb.
|
|
1025
|
+
if (++tick % 18 === 0) {
|
|
1026
|
+
vi += 1;
|
|
1027
|
+
setVerb();
|
|
1028
|
+
}
|
|
1029
|
+
}, 90);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private stopThinking() {
|
|
1033
|
+
if (this.thinkTimer !== undefined) {
|
|
1034
|
+
window.clearInterval(this.thinkTimer);
|
|
1035
|
+
this.thinkTimer = undefined;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private scheduleAnswerRender(answer: string, sources: Source[]) {
|
|
1040
|
+
if (this.answerRaf !== undefined) return;
|
|
1041
|
+
this.answerRaf = window.requestAnimationFrame(() => {
|
|
1042
|
+
this.answerRaf = undefined;
|
|
1043
|
+
this.answerBody.innerHTML = renderMarkdown(answer, sources);
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
private flushAnswerRender(answer: string, sources: Source[]) {
|
|
1048
|
+
if (this.answerRaf !== undefined) {
|
|
1049
|
+
window.cancelAnimationFrame(this.answerRaf);
|
|
1050
|
+
this.answerRaf = undefined;
|
|
1051
|
+
}
|
|
1052
|
+
this.answerBody.innerHTML = renderMarkdown(answer, sources);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
private renderSources(sources: Source[]) {
|
|
1056
|
+
this.answerSources.innerHTML = '';
|
|
1057
|
+
if (!sources.length) {
|
|
1058
|
+
this.answerSources.hidden = true;
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const label = document.createElement('span');
|
|
1062
|
+
label.className = 'as-answer-sources-label';
|
|
1063
|
+
label.textContent = 'Sources';
|
|
1064
|
+
this.answerSources.appendChild(label);
|
|
1065
|
+
for (const source of sources) {
|
|
1066
|
+
const chip = document.createElement('a');
|
|
1067
|
+
chip.className = 'as-source-chip';
|
|
1068
|
+
chip.href = source.url;
|
|
1069
|
+
chip.textContent = sourceBreadcrumb(source);
|
|
1070
|
+
this.answerSources.appendChild(chip);
|
|
1071
|
+
}
|
|
1072
|
+
this.answerSources.hidden = false;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
private showAnswer() {
|
|
1076
|
+
this.results.innerHTML = '';
|
|
1077
|
+
this.answer.hidden = false;
|
|
1078
|
+
this.answerSources.hidden = true;
|
|
1079
|
+
this.answerSources.innerHTML = '';
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
private clearAnswer() {
|
|
1083
|
+
this.stopThinking();
|
|
1084
|
+
if (this.answerRaf !== undefined) {
|
|
1085
|
+
window.cancelAnimationFrame(this.answerRaf);
|
|
1086
|
+
this.answerRaf = undefined;
|
|
1087
|
+
}
|
|
1088
|
+
this.answer.hidden = true;
|
|
1089
|
+
this.answerBody.classList.remove('is-streaming');
|
|
1090
|
+
this.answerBody.innerHTML = '';
|
|
1091
|
+
this.answerSources.hidden = true;
|
|
1092
|
+
this.answerSources.innerHTML = '';
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private showSearched(text: string) {
|
|
1096
|
+
this.searched.classList.remove('as-warning');
|
|
1097
|
+
this.searched.textContent = text;
|
|
1098
|
+
this.searched.hidden = false;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
private setState(message: string, isError = false) {
|
|
1102
|
+
this.clearAnswer();
|
|
1103
|
+
this.items = [];
|
|
1104
|
+
this.active = -1;
|
|
1105
|
+
this.userSelected = false;
|
|
1106
|
+
this.searched.hidden = true;
|
|
1107
|
+
this.searched.textContent = '';
|
|
1108
|
+
this.searched.classList.remove('as-warning');
|
|
1109
|
+
this.results.innerHTML = `<div class="as-state${isError ? ' as-error' : ''}"></div>`;
|
|
1110
|
+
(this.results.firstElementChild as HTMLElement).textContent = message;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Empty-state suggested questions. Clicking one fills the input and asks it. */
|
|
1114
|
+
private renderSuggestions() {
|
|
1115
|
+
this.results.innerHTML = '';
|
|
1116
|
+
this.items = [];
|
|
1117
|
+
this.active = -1;
|
|
1118
|
+
this.userSelected = false;
|
|
1119
|
+
this.searched.hidden = true;
|
|
1120
|
+
this.searched.textContent = '';
|
|
1121
|
+
this.searched.classList.remove('as-warning');
|
|
1122
|
+
|
|
1123
|
+
const label = document.createElement('div');
|
|
1124
|
+
label.className = 'as-suggest-label';
|
|
1125
|
+
label.textContent = 'Ask anything';
|
|
1126
|
+
this.results.appendChild(label);
|
|
1127
|
+
|
|
1128
|
+
for (const question of this.suggestions) {
|
|
1129
|
+
const row = document.createElement('button');
|
|
1130
|
+
row.type = 'button';
|
|
1131
|
+
row.className = 'as-suggest';
|
|
1132
|
+
row.textContent = question;
|
|
1133
|
+
row.addEventListener('click', () => {
|
|
1134
|
+
this.input.value = question;
|
|
1135
|
+
this.syncAskButton();
|
|
1136
|
+
this.runAgentic(question);
|
|
1137
|
+
});
|
|
1138
|
+
this.results.appendChild(row);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Ask-mode affordance: a single primary row that runs the agentic loop. */
|
|
1143
|
+
private renderAsk(query: string) {
|
|
1144
|
+
this.results.innerHTML = '';
|
|
1145
|
+
this.items = [];
|
|
1146
|
+
this.active = -1;
|
|
1147
|
+
this.userSelected = false;
|
|
1148
|
+
this.searched.hidden = true;
|
|
1149
|
+
this.searched.textContent = '';
|
|
1150
|
+
this.searched.classList.remove('as-warning');
|
|
1151
|
+
|
|
1152
|
+
const row = document.createElement('button');
|
|
1153
|
+
row.type = 'button';
|
|
1154
|
+
row.className = 'as-ask-row';
|
|
1155
|
+
const hint = document.createElement('span');
|
|
1156
|
+
hint.className = 'as-ask-row-hint';
|
|
1157
|
+
hint.textContent = 'Press Enter to send your question';
|
|
1158
|
+
const kbd = document.createElement('kbd');
|
|
1159
|
+
kbd.className = 'as-kbd';
|
|
1160
|
+
kbd.textContent = '↵';
|
|
1161
|
+
row.append(hint, kbd);
|
|
1162
|
+
row.addEventListener('click', () => this.runAgentic(query));
|
|
1163
|
+
this.results.appendChild(row);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private applyModePlaceholder() {
|
|
1167
|
+
this.input.placeholder = this.agentic ? 'Ask a question, or type to search…' : this.defaultPlaceholder;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
private readPref(): boolean {
|
|
1171
|
+
try {
|
|
1172
|
+
return localStorage.getItem('hev-ask:mode') !== 'keyword';
|
|
1173
|
+
} catch {
|
|
1174
|
+
return true;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
private writePref() {
|
|
1179
|
+
try {
|
|
1180
|
+
localStorage.setItem('hev-ask:mode', this.agentic ? 'agentic' : 'keyword');
|
|
1181
|
+
} catch {
|
|
1182
|
+
/* storage unavailable — preference is session-only */
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
private setModel(model: string) {
|
|
1187
|
+
this.model = model;
|
|
1188
|
+
this.updateModeUI();
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private updateModeUI() {
|
|
1192
|
+
if (!this.modeEl || !this.modeLabelEl || !this.modeToggleEl) return;
|
|
1193
|
+
if (this.agentic) {
|
|
1194
|
+
this.modeEl.classList.remove('as-mode--off');
|
|
1195
|
+
this.modeLabelEl.textContent = this.model || 'AI on Enter';
|
|
1196
|
+
this.modeToggleEl.textContent = '✕';
|
|
1197
|
+
this.modeToggleEl.title = 'Disable AI on Enter';
|
|
1198
|
+
this.modeToggleEl.setAttribute('aria-label', 'Disable AI on Enter');
|
|
1199
|
+
this.hint.innerHTML =
|
|
1200
|
+
'<kbd class="as-kbd">↵</kbd> ask AI · <kbd class="as-kbd">↑</kbd><kbd class="as-kbd">↓</kbd> select';
|
|
1201
|
+
} else {
|
|
1202
|
+
this.modeEl.classList.add('as-mode--off');
|
|
1203
|
+
this.modeLabelEl.textContent = 'keyword only';
|
|
1204
|
+
this.modeToggleEl.textContent = 'AI';
|
|
1205
|
+
this.modeToggleEl.title = 'Enable AI on Enter';
|
|
1206
|
+
this.modeToggleEl.setAttribute('aria-label', 'Enable AI on Enter');
|
|
1207
|
+
this.hint.innerHTML =
|
|
1208
|
+
'<kbd class="as-kbd">↑</kbd><kbd class="as-kbd">↓</kbd> navigate <kbd class="as-kbd">↵</kbd> open';
|
|
1209
|
+
}
|
|
1210
|
+
this.modeEl.hidden = false;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
private toggleMode() {
|
|
1214
|
+
this.agentic = !this.agentic;
|
|
1215
|
+
this.writePref();
|
|
1216
|
+
this.updateModeUI();
|
|
1217
|
+
this.applyModePlaceholder();
|
|
1218
|
+
this.syncAskButton();
|
|
1219
|
+
if (this.agentic) void this.maybeFetchSuggestions();
|
|
1220
|
+
const q = this.input.value.trim();
|
|
1221
|
+
if (!q) {
|
|
1222
|
+
this.showInitial();
|
|
1223
|
+
} else if (this.agentic && /\s/.test(q)) {
|
|
1224
|
+
this.enterAskMode(q);
|
|
1225
|
+
} else {
|
|
1226
|
+
this.lastQuery = '';
|
|
1227
|
+
this.searchKeyword(q);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
private syncAskButton() {
|
|
1232
|
+
const hasQuery = Boolean(this.input.value.trim());
|
|
1233
|
+
const isAsking = this.activeMode === 'agentic';
|
|
1234
|
+
this.askButton.disabled = !hasQuery || isAsking;
|
|
1235
|
+
this.askButton.classList.toggle('as-ai-button--loading', isAsking);
|
|
1236
|
+
this.askButtonLabel.textContent = isAsking ? 'Asking' : 'Ask AI';
|
|
1237
|
+
this.askButton.title = hasQuery ? 'Ask AI search' : 'Type a query to ask AI';
|
|
1238
|
+
this.askButton.setAttribute('aria-label', this.askButton.title);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
private render(results: SearchResult[], searches: string[] = [], warning = '') {
|
|
1242
|
+
this.clearAnswer();
|
|
1243
|
+
this.results.innerHTML = '';
|
|
1244
|
+
this.searched.classList.remove('as-warning');
|
|
1245
|
+
this.searched.hidden = true;
|
|
1246
|
+
this.searched.textContent = '';
|
|
1247
|
+
this.items = [];
|
|
1248
|
+
this.active = -1;
|
|
1249
|
+
this.userSelected = false;
|
|
1250
|
+
for (const r of results) {
|
|
1251
|
+
const a = document.createElement('a');
|
|
1252
|
+
a.className = 'as-result';
|
|
1253
|
+
a.href = r.url;
|
|
1254
|
+
a.setAttribute('role', 'option');
|
|
1255
|
+
|
|
1256
|
+
const top = document.createElement('div');
|
|
1257
|
+
top.className = 'as-result-top';
|
|
1258
|
+
const title = document.createElement('span');
|
|
1259
|
+
title.className = 'as-result-title';
|
|
1260
|
+
title.textContent = r.title;
|
|
1261
|
+
top.appendChild(title);
|
|
1262
|
+
if (r.group) {
|
|
1263
|
+
const group = document.createElement('span');
|
|
1264
|
+
group.className = 'as-result-group';
|
|
1265
|
+
group.textContent = r.group;
|
|
1266
|
+
top.appendChild(group);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const heading = document.createElement('div');
|
|
1270
|
+
heading.className = 'as-result-heading';
|
|
1271
|
+
heading.textContent = r.heading ? `${r.group ? `${r.group} › ` : ''}${r.heading}` : '';
|
|
1272
|
+
heading.hidden = !r.heading;
|
|
1273
|
+
|
|
1274
|
+
const snippet = document.createElement('div');
|
|
1275
|
+
snippet.className = 'as-result-snippet';
|
|
1276
|
+
snippet.textContent = r.snippet;
|
|
1277
|
+
|
|
1278
|
+
a.append(top, heading, snippet);
|
|
1279
|
+
a.addEventListener('mousemove', () => this.setActive(this.items.indexOf(a), true));
|
|
1280
|
+
this.results.appendChild(a);
|
|
1281
|
+
this.items.push(a);
|
|
1282
|
+
}
|
|
1283
|
+
if (warning) {
|
|
1284
|
+
const searched = searches.length ? ` searched: ${searches.join(', ')}` : '';
|
|
1285
|
+
this.searched.textContent = `${warning}${searched}`;
|
|
1286
|
+
this.searched.classList.add('as-warning');
|
|
1287
|
+
this.searched.hidden = false;
|
|
1288
|
+
} else if (searches.length) {
|
|
1289
|
+
this.searched.textContent = `searched: ${searches.join(', ')}`;
|
|
1290
|
+
this.searched.hidden = false;
|
|
1291
|
+
}
|
|
1292
|
+
if (this.items.length) this.setActive(0);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private setActive(i: number, userSelected = false) {
|
|
1296
|
+
if (i < 0 || i >= this.items.length) return;
|
|
1297
|
+
if (i === this.active) {
|
|
1298
|
+
if (userSelected) this.userSelected = true;
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
this.items[this.active]?.setAttribute('aria-selected', 'false');
|
|
1302
|
+
this.active = i;
|
|
1303
|
+
if (userSelected) this.userSelected = true;
|
|
1304
|
+
const el = this.items[i];
|
|
1305
|
+
el.setAttribute('aria-selected', 'true');
|
|
1306
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
private onKeydown(e: KeyboardEvent) {
|
|
1310
|
+
// Tab accepts the first suggested question into the input (empty + asking).
|
|
1311
|
+
if (e.key === 'Tab' && !e.shiftKey && this.agentic && !this.input.value.trim() && this.suggestions.length) {
|
|
1312
|
+
e.preventDefault();
|
|
1313
|
+
this.input.value = this.suggestions[0];
|
|
1314
|
+
this.onInput();
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (e.key === 'ArrowDown') {
|
|
1318
|
+
e.preventDefault();
|
|
1319
|
+
e.stopPropagation();
|
|
1320
|
+
this.setActive(Math.min(this.active + 1, this.items.length - 1), true);
|
|
1321
|
+
} else if (e.key === 'ArrowUp') {
|
|
1322
|
+
e.preventDefault();
|
|
1323
|
+
e.stopPropagation();
|
|
1324
|
+
this.setActive(Math.max(this.active - 1, 0), true);
|
|
1325
|
+
} else if (e.key === 'Enter') {
|
|
1326
|
+
const el = this.items[this.active];
|
|
1327
|
+
const q = this.input.value.trim();
|
|
1328
|
+
if (el && (this.userSelected || !this.agentic || !q)) {
|
|
1329
|
+
e.preventDefault();
|
|
1330
|
+
e.stopPropagation();
|
|
1331
|
+
window.location.href = el.href;
|
|
1332
|
+
} else if (q && this.agentic) {
|
|
1333
|
+
e.preventDefault();
|
|
1334
|
+
e.stopPropagation();
|
|
1335
|
+
this.runAgentic(q);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function init() {
|
|
1342
|
+
const dialog = document.querySelector('.as-overlay') as HTMLDialogElement | null;
|
|
1343
|
+
if (!dialog) return;
|
|
1344
|
+
const overlay = new HevAskOverlay(dialog);
|
|
1345
|
+
|
|
1346
|
+
// Global hotkeys: ⌘K / Ctrl+K anywhere, or "/" when not typing in a field.
|
|
1347
|
+
document.addEventListener('keydown', (e) => {
|
|
1348
|
+
const k = e.key.toLowerCase();
|
|
1349
|
+
const inField =
|
|
1350
|
+
document.activeElement instanceof HTMLElement &&
|
|
1351
|
+
['input', 'textarea', 'select'].includes(document.activeElement.tagName.toLowerCase());
|
|
1352
|
+
if ((e.metaKey || e.ctrlKey) && k === 'k') {
|
|
1353
|
+
e.preventDefault();
|
|
1354
|
+
overlay.open();
|
|
1355
|
+
} else if (k === '/' && !inField && !dialog.open) {
|
|
1356
|
+
e.preventDefault();
|
|
1357
|
+
overlay.open();
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// Any element with [data-hev-ask-open] opens the overlay.
|
|
1362
|
+
document.querySelectorAll('[data-hev-ask-open]').forEach((el) => {
|
|
1363
|
+
el.addEventListener('click', (e) => {
|
|
1364
|
+
e.preventDefault();
|
|
1365
|
+
overlay.open();
|
|
1366
|
+
});
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (document.readyState === 'loading') {
|
|
1371
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
1372
|
+
} else {
|
|
1373
|
+
init();
|
|
1374
|
+
}
|
|
1375
|
+
</script>
|