@veluai/velu 0.2.13 → 0.2.15
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 +80 -80
- package/dist/cli.js +37 -37
- package/package.json +64 -64
- package/runtime/velu-ui/base.css +320 -320
- package/runtime/velu-ui/components/Accordion.jsx +64 -64
- package/runtime/velu-ui/components/ApiClient.jsx +207 -207
- package/runtime/velu-ui/components/ApiField.jsx +87 -87
- package/runtime/velu-ui/components/ApiPath.jsx +63 -63
- package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
- package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
- package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
- package/runtime/velu-ui/components/AskBar.jsx +71 -71
- package/runtime/velu-ui/components/Callout.jsx +114 -114
- package/runtime/velu-ui/components/Card.jsx +131 -131
- package/runtime/velu-ui/components/Chatbot.jsx +885 -885
- package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
- package/runtime/velu-ui/components/Columns.jsx +56 -56
- package/runtime/velu-ui/components/ContextMenu.jsx +298 -273
- package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
- package/runtime/velu-ui/components/Field.jsx +81 -81
- package/runtime/velu-ui/components/Image.jsx +163 -163
- package/runtime/velu-ui/components/Logo.jsx +31 -31
- package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
- package/runtime/velu-ui/components/NavSelect.jsx +108 -108
- package/runtime/velu-ui/components/NotFound.jsx +63 -63
- package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
- package/runtime/velu-ui/components/PageFooter.jsx +145 -145
- package/runtime/velu-ui/components/PageHeader.jsx +422 -422
- package/runtime/velu-ui/components/PageNav.jsx +77 -77
- package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
- package/runtime/velu-ui/components/Prompt.jsx +115 -115
- package/runtime/velu-ui/components/Search.jsx +460 -460
- package/runtime/velu-ui/components/Sidebar.jsx +254 -254
- package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
- package/runtime/velu-ui/components/Steps.jsx +65 -65
- package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
- package/runtime/velu-ui/components/Toc.jsx +537 -537
- package/runtime/velu-ui/components/TocBar.jsx +195 -195
- package/runtime/velu-ui/components/Tree.jsx +87 -87
- package/runtime/velu-ui/components/TryItBar.jsx +102 -102
- package/runtime/velu-ui/components/accordion.css +92 -92
- package/runtime/velu-ui/components/api-page.css +208 -208
- package/runtime/velu-ui/components/api.css +635 -635
- package/runtime/velu-ui/components/ask-bar.css +94 -94
- package/runtime/velu-ui/components/card.css +105 -105
- package/runtime/velu-ui/components/chatbot.css +622 -622
- package/runtime/velu-ui/components/code-block.css +263 -263
- package/runtime/velu-ui/components/context-menu.css +173 -173
- package/runtime/velu-ui/components/docs-layout.css +822 -822
- package/runtime/velu-ui/components/field.css +82 -82
- package/runtime/velu-ui/components/image.css +237 -237
- package/runtime/velu-ui/components/nav-select.css +157 -157
- package/runtime/velu-ui/components/not-found.css +94 -94
- package/runtime/velu-ui/components/page-feedback.css +241 -241
- package/runtime/velu-ui/components/page-footer.css +130 -130
- package/runtime/velu-ui/components/page-header.css +558 -558
- package/runtime/velu-ui/components/page-nav.css +50 -50
- package/runtime/velu-ui/components/powered-by.css +92 -92
- package/runtime/velu-ui/components/prompt.css +99 -99
- package/runtime/velu-ui/components/search.css +307 -307
- package/runtime/velu-ui/components/sidebar.css +205 -205
- package/runtime/velu-ui/components/steps.css +77 -77
- package/runtime/velu-ui/components/theme-toggle.css +102 -102
- package/runtime/velu-ui/components/toc-bar.css +234 -234
- package/runtime/velu-ui/components/tree.css +49 -49
- package/runtime/velu-ui/index.js +54 -54
- package/runtime/velu-ui/lib/api-send.js +92 -92
- package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
- package/runtime/velu-ui/lib/component-schemas.js +100 -100
- package/runtime/velu-ui/lib/copyText.js +64 -64
- package/runtime/velu-ui/lib/docs-assistant.js +250 -250
- package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
- package/runtime/velu-ui/lib/pagefind.js +113 -113
- package/runtime/velu-ui/lib/prism-langs.js +957 -957
- package/runtime/velu-ui/lib/prism-loader.js +74 -74
- package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
- package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
- package/runtime/velu-ui/mdx-components.jsx +105 -105
- package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
- package/runtime/velu-ui/primitives/Stack.jsx +63 -63
- package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
- package/runtime/velu-ui/primitives/stack.css +3 -3
- package/runtime/velu-ui/primitives/switcher.css +25 -25
- package/runtime/velu-ui/styles.css +46 -46
- package/runtime/velu-ui/tokens.css +4 -4
- package/schema/velu.schema.json +423 -423
- package/src/lib/extract-mdx-error.js +170 -170
- package/src/lib/issues.js +159 -159
- package/src/lib/known-components.js +34 -34
- package/src/navigation.js +443 -443
- package/src/runtime/App.jsx +1669 -1668
- package/src/runtime/ErrorBoundary.jsx +54 -54
- package/src/runtime/client-entry.jsx +22 -22
- package/src/runtime/server-entry.jsx +16 -16
- package/src/template.html +48 -48
- package/templates/starter/ai-tools/claude-code.mdx +26 -26
- package/templates/starter/ai-tools/cursor.mdx +17 -17
- package/templates/starter/api-reference/introduction.mdx +43 -43
- package/templates/starter/development.mdx +19 -19
- package/templates/starter/essentials/code.mdx +29 -29
- package/templates/starter/essentials/images.mdx +29 -29
- package/templates/starter/essentials/markdown.mdx +25 -25
- package/templates/starter/essentials/navigation.mdx +39 -39
- package/templates/starter/essentials/settings.mdx +30 -30
- package/templates/starter/favicon.svg +6 -6
- package/templates/starter/index.mdx +31 -31
- package/templates/starter/openapi.json +160 -160
- package/templates/starter/quickstart.mdx +31 -31
- package/templates/starter/velu.json +41 -41
|
@@ -1,885 +1,885 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
-
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
3
|
-
import Stack from '../primitives/Stack.jsx';
|
|
4
|
-
import Cluster from '../primitives/Cluster.jsx';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Chatbot — the Velu "Ask AI" panel. A right-side slide-out: header +
|
|
8
|
-
* scrolling message body + composer. Ported from the Claude-Design
|
|
9
|
-
* "Velu Chatbot" handoff (rich variant), retokenized onto velu-ui's
|
|
10
|
-
* scale so it themes for free via [data-theme].
|
|
11
|
-
*
|
|
12
|
-
* <Chatbot
|
|
13
|
-
* open={chatOpen}
|
|
14
|
-
* seedQuestion={question} // first message — sent on open
|
|
15
|
-
* onClose={() => setChatOpen(false)}
|
|
16
|
-
* ask={askDocs} // OPTIONAL — real backend (see below)
|
|
17
|
-
* onFeedback={(id, v) => …} // OPTIONAL — 👍/👎 for a message
|
|
18
|
-
* />
|
|
19
|
-
*
|
|
20
|
-
* Slide: the panel is fixed to the inline-end edge; `open` toggles a
|
|
21
|
-
* `translateX` between fully off-screen and 0. Width is the
|
|
22
|
-
* `--vchat-width` variable (default 24rem ≈ the design's 384px).
|
|
23
|
-
*
|
|
24
|
-
* Backend seam (mirrors Search.jsx's injectable `search` prop):
|
|
25
|
-
* ask(prompt, { conversationId, signal }) → AsyncIterable<AskEvent>
|
|
26
|
-
* AskEvent = {
|
|
27
|
-
* delta?: string, // text chunk to append (streamed)
|
|
28
|
-
* citations?: Source[], // [{ num, title, path, url }] — replaces sources
|
|
29
|
-
* messageId?: string, // assistant message id (for feedback)
|
|
30
|
-
* conversationId?: string, // threaded across turns
|
|
31
|
-
* }
|
|
32
|
-
* When `ask` is omitted the panel falls back to a canned, fake-streamed
|
|
33
|
-
* demo answer (so dev preview / offline still shows the experience).
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/* ── Brand mark — the Velu double-hook logo ─────────────────────────── */
|
|
37
|
-
// `size` is the glyph height; width derives from the 32:24 viewBox.
|
|
38
|
-
function VeluMark({ size = 'var(--icon-size-lg)' }) {
|
|
39
|
-
return (
|
|
40
|
-
<svg
|
|
41
|
-
className="velu-chatbot__mark"
|
|
42
|
-
viewBox="0 0 32 24"
|
|
43
|
-
fill="currentColor"
|
|
44
|
-
aria-hidden="true"
|
|
45
|
-
style={{ width: `calc(${size} * (32 / 24))`, height: size }}
|
|
46
|
-
>
|
|
47
|
-
<path d="M 29.656 14.153 C 29.574 14.562 29.351 14.929 29.025 15.193 C 28.699 15.457 28.291 15.601 27.869 15.601 L 21.37 15.601 C 20.534 15.601 19.805 16.163 19.603 16.964 L 18.167 22.637 C 18.068 23.026 17.841 23.372 17.521 23.619 C 17.2 23.866 16.805 24 16.399 24 L 10.438 24 C 10.161 24 9.888 23.938 9.639 23.818 C 9.39 23.698 9.172 23.524 9.001 23.308 C 8.831 23.092 8.712 22.841 8.655 22.574 C 8.598 22.306 8.603 22.029 8.67 21.764 L 10.189 15.764 C 10.287 15.374 10.515 15.029 10.835 14.782 C 11.155 14.535 11.55 14.401 11.956 14.4 L 18.393 14.4 C 19.262 14.4 20.01 13.795 20.18 12.953 L 22.388 2.048 C 22.471 1.639 22.694 1.272 23.019 1.008 C 23.345 0.744 23.754 0.6 24.175 0.6 L 30.177 0.6 C 30.447 0.6 30.713 0.659 30.957 0.773 C 31.201 0.886 31.416 1.052 31.587 1.258 C 31.758 1.464 31.881 1.705 31.946 1.964 C 32.011 2.222 32.017 2.492 31.964 2.753 L 29.656 14.153 Z M 9.611 13.554 C 9.528 13.962 9.305 14.329 8.979 14.593 C 8.653 14.857 8.245 15.001 7.824 15.001 L 1.823 15.001 C 1.553 15.001 1.287 14.942 1.043 14.828 C 0.799 14.714 0.584 14.548 0.413 14.342 C 0.242 14.136 0.119 13.895 0.054 13.637 C -0.011 13.378 -0.017 13.109 0.036 12.848 L 2.344 1.447 C 2.426 1.039 2.649 0.672 2.975 0.408 C 3.301 0.144 3.709 0 4.131 0 L 10.132 0 C 10.402 0 10.668 0.059 10.912 0.173 C 11.155 0.287 11.371 0.453 11.541 0.659 C 11.712 0.865 11.835 1.106 11.9 1.364 C 11.965 1.623 11.972 1.892 11.919 2.153 L 9.611 13.553 L 9.611 13.554 Z" />
|
|
48
|
-
</svg>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/* ── "Thinking" loader — 3x3 grid of squares, opacity wave sweeps the
|
|
53
|
-
anti-diagonal so it ripples top-left → bottom-right. ─────────────── */
|
|
54
|
-
function BlocksWave() {
|
|
55
|
-
const cells = [
|
|
56
|
-
[1, 1, 0], [8.33, 1, 1], [15.66, 1, 2],
|
|
57
|
-
[1, 8.33, 1], [8.33, 8.33, 2], [15.66, 8.33, 3],
|
|
58
|
-
[1, 15.66, 2], [8.33, 15.66, 3], [15.66, 15.66, 4],
|
|
59
|
-
];
|
|
60
|
-
return (
|
|
61
|
-
<svg className="velu-chatbot__wave" viewBox="0 0 24 24" aria-hidden="true">
|
|
62
|
-
{cells.map(([x, y, cell], i) => (
|
|
63
|
-
<rect key={i} x={x} y={y} width="7.33" height="7.33" data-cell={cell} />
|
|
64
|
-
))}
|
|
65
|
-
</svg>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/* ── Demo data ──────────────────────────────────────────────────────── */
|
|
70
|
-
const SUGGESTIONS = [
|
|
71
|
-
{ label: 'How do I set up GitHub sync?', icon: 'github' },
|
|
72
|
-
{ label: 'Customize the look of my docs site', icon: 'book' },
|
|
73
|
-
{ label: 'Configure AI search ranking', icon: 'sparkles' },
|
|
74
|
-
{ label: 'Quickstart in under five minutes', icon: 'rocket' },
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
const HISTORY = [
|
|
78
|
-
{ id: 'h1', title: 'Setting up GitHub sync', when: 'Today, 10:24' },
|
|
79
|
-
{ id: 'h2', title: 'Custom theme tokens & dark mode', when: 'Today, 09:11' },
|
|
80
|
-
{ id: 'h3', title: 'Configure AI search ranking', when: 'Yesterday' },
|
|
81
|
-
{ id: 'h4', title: 'Embed a Mermaid diagram in MDX', when: 'Yesterday' },
|
|
82
|
-
{ id: 'h5', title: 'Why are my callouts missing icons?', when: 'Apr 28' },
|
|
83
|
-
{ id: 'h6', title: 'Quickstart — minimum site config', when: 'Apr 27' },
|
|
84
|
-
];
|
|
85
|
-
|
|
86
|
-
const SOURCES = [
|
|
87
|
-
{ num: 1, title: 'GitHub & GitLab Sync', path: 'docs/integrations/github-sync.md' },
|
|
88
|
-
{ num: 2, title: 'Site Settings — Repositories', path: 'docs/your-docs-site/site-settings.md' },
|
|
89
|
-
{ num: 3, title: 'AI-native Documentation', path: 'docs/get-started/ai-native.md' },
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
/* Canned answer — segments interleave text, inline `code`, and citation
|
|
93
|
-
markers. The same answer is returned for any prompt (demo). */
|
|
94
|
-
function buildAnswer() {
|
|
95
|
-
return {
|
|
96
|
-
segments: [
|
|
97
|
-
{ t: 'Velu syncs documentation directly with GitHub and GitLab — every push to a connected repository updates the live docs site within seconds.' },
|
|
98
|
-
{ cite: 1 },
|
|
99
|
-
{ t: ' To set it up:\n\n1. Open ' },
|
|
100
|
-
{ code: 'Site Settings → Repositories' },
|
|
101
|
-
{ t: ' and click ' },
|
|
102
|
-
{ code: 'Connect repository' },
|
|
103
|
-
{ t: '.\n2. Authorize the Velu app on the org that owns the repo.\n3. Pick a branch (default ' },
|
|
104
|
-
{ code: 'main' },
|
|
105
|
-
{ t: ') and a docs root path.' },
|
|
106
|
-
{ cite: 2 },
|
|
107
|
-
{ t: '\n\nVelu watches changes to ' },
|
|
108
|
-
{ code: '.md' },
|
|
109
|
-
{ t: ' and ' },
|
|
110
|
-
{ code: '.mdx' },
|
|
111
|
-
{ t: ' files and re-indexes the site automatically.' },
|
|
112
|
-
{ cite: 3 },
|
|
113
|
-
],
|
|
114
|
-
sources: SOURCES,
|
|
115
|
-
followups: [
|
|
116
|
-
'How do I preview pull-request changes?',
|
|
117
|
-
'Can I sync from a monorepo subdirectory?',
|
|
118
|
-
'What about private repositories?',
|
|
119
|
-
],
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/* Real-backend path: turn the accumulated answer TEXT into the same token
|
|
124
|
-
shape `renderStream` consumes. The assistant streams plain text with inline
|
|
125
|
-
`code` spans and bracketed citation markers ([1], [2]); split those out so
|
|
126
|
-
they render as <code> and citation chips, exactly like the canned path. */
|
|
127
|
-
function textToTokens(text) {
|
|
128
|
-
const tokens = [];
|
|
129
|
-
const re = /(`[^`]*`|\[\d+\])/g;
|
|
130
|
-
let last = 0;
|
|
131
|
-
let m;
|
|
132
|
-
while ((m = re.exec(text)) !== null) {
|
|
133
|
-
if (m.index > last) tokens.push({ kind: 't', v: text.slice(last, m.index) });
|
|
134
|
-
const tok = m[0];
|
|
135
|
-
if (tok[0] === '`') tokens.push({ kind: 'code', v: tok.slice(1, -1) });
|
|
136
|
-
else tokens.push({ kind: 'cite', n: Number(tok.slice(1, -1)) });
|
|
137
|
-
last = re.lastIndex;
|
|
138
|
-
}
|
|
139
|
-
if (last < text.length) tokens.push({ kind: 't', v: text.slice(last) });
|
|
140
|
-
return tokens;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/* Plain-text of an answer (for the Copy action). */
|
|
144
|
-
function tokensToPlain(tokens) {
|
|
145
|
-
return tokens
|
|
146
|
-
.map((tk) => (tk.kind === 'cite' ? `[${tk.n}]` : tk.v))
|
|
147
|
-
.join('')
|
|
148
|
-
.trim();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/* Flatten an answer into a stream of small tokens for incremental render. */
|
|
152
|
-
function tokenizeAnswer(answer) {
|
|
153
|
-
const tokens = [];
|
|
154
|
-
for (const seg of answer.segments) {
|
|
155
|
-
if (seg.cite != null) { tokens.push({ kind: 'cite', n: seg.cite }); continue; }
|
|
156
|
-
if (seg.code != null) { tokens.push({ kind: 'code', v: seg.code }); continue; }
|
|
157
|
-
const parts = seg.t.match(/\S+\s*|\s+/g) || [seg.t];
|
|
158
|
-
for (const p of parts) tokens.push({ kind: 't', v: p });
|
|
159
|
-
}
|
|
160
|
-
return tokens;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/* A citation's in-site target. Real backend sources carry a `url` (and `path`
|
|
164
|
-
is a route like "/essentials/code"); the demo's `path` is a fake file path,
|
|
165
|
-
so those stay inert. */
|
|
166
|
-
function sourceHref(s) {
|
|
167
|
-
if (s?.url) return s.url;
|
|
168
|
-
if (s?.path && s.path.startsWith('/')) return s.path;
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/* A readable label for a source: the section heading if present, else a title
|
|
173
|
-
derived from the route (e.g. "/essentials/settings" → "Settings"). */
|
|
174
|
-
function citeTitle(s) {
|
|
175
|
-
if (s?.title) return s.title;
|
|
176
|
-
const path = (s?.path || '').replace(/[#?].*$/, '');
|
|
177
|
-
const seg = path.split('/').filter(Boolean).pop();
|
|
178
|
-
if (!seg) return 'Documentation';
|
|
179
|
-
return seg.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/* Source click: SPA-navigate same-origin targets via onNavigate (keeps the chat
|
|
183
|
-
panel open + scrolls to the heading); otherwise let the <a href> navigate. */
|
|
184
|
-
function onSourceClick(e, s, onNavigate) {
|
|
185
|
-
const href = sourceHref(s);
|
|
186
|
-
if (!href) { e.preventDefault(); return; }
|
|
187
|
-
if (!onNavigate) return; // no SPA hook → let the browser follow the href
|
|
188
|
-
try {
|
|
189
|
-
const u = new URL(href, window.location.origin);
|
|
190
|
-
if (u.origin === window.location.origin) {
|
|
191
|
-
e.preventDefault();
|
|
192
|
-
onNavigate(u.pathname + u.search + u.hash);
|
|
193
|
-
}
|
|
194
|
-
} catch { /* malformed — fall back to href */ }
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/* Render partial tokens as paragraphs (split on \n\n, single \n → <br>). */
|
|
198
|
-
function renderStream(tokens, sources, onNavigate) {
|
|
199
|
-
const paragraphs = [[]];
|
|
200
|
-
for (const tk of tokens) {
|
|
201
|
-
if (tk.kind === 't' && tk.v.includes('\n\n')) {
|
|
202
|
-
const split = tk.v.split('\n\n');
|
|
203
|
-
paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[0] });
|
|
204
|
-
for (let i = 1; i < split.length; i++) {
|
|
205
|
-
paragraphs.push([]);
|
|
206
|
-
if (split[i]) paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[i] });
|
|
207
|
-
}
|
|
208
|
-
} else {
|
|
209
|
-
paragraphs[paragraphs.length - 1].push(tk);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return paragraphs.map((para, pi) => (
|
|
213
|
-
<p key={pi}>
|
|
214
|
-
{para.map((tk, i) => {
|
|
215
|
-
if (tk.kind === 'cite') {
|
|
216
|
-
const src = sources?.find((s) => s.num === tk.n);
|
|
217
|
-
return (
|
|
218
|
-
<a
|
|
219
|
-
key={i}
|
|
220
|
-
className="velu-chatbot__cite"
|
|
221
|
-
href={sourceHref(src) || '#'}
|
|
222
|
-
onClick={(e) => onSourceClick(e, src, onNavigate)}
|
|
223
|
-
title={citeTitle(src)}
|
|
224
|
-
>
|
|
225
|
-
{tk.n}
|
|
226
|
-
</a>
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
if (tk.kind === 'code') return <code key={i}>{tk.v}</code>;
|
|
230
|
-
const lines = tk.v.split('\n');
|
|
231
|
-
return lines.map((line, li) => (
|
|
232
|
-
<React.Fragment key={`${i}-${li}`}>
|
|
233
|
-
{li > 0 && <br />}
|
|
234
|
-
{line}
|
|
235
|
-
</React.Fragment>
|
|
236
|
-
));
|
|
237
|
-
})}
|
|
238
|
-
</p>
|
|
239
|
-
));
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/* ── Header ─────────────────────────────────────────────────────────── */
|
|
243
|
-
function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
|
|
244
|
-
return (
|
|
245
|
-
<Cluster
|
|
246
|
-
space="var(--s-2)"
|
|
247
|
-
justify="space-between"
|
|
248
|
-
align="center"
|
|
249
|
-
className="velu-chatbot__header"
|
|
250
|
-
>
|
|
251
|
-
<Cluster space="var(--s-2)" align="center">
|
|
252
|
-
<VeluMark />
|
|
253
|
-
<span className="velu-chatbot__brand">
|
|
254
|
-
<span className="velu-chatbot__brand-name">Velu</span>
|
|
255
|
-
<span className="velu-chatbot__brand-sep">/</span>
|
|
256
|
-
<span className="velu-chatbot__brand-title">Ask AI</span>
|
|
257
|
-
</span>
|
|
258
|
-
</Cluster>
|
|
259
|
-
<Cluster space="var(--s-5)" align="center">
|
|
260
|
-
<button
|
|
261
|
-
type="button"
|
|
262
|
-
className={`velu-chatbot__iconbtn${historyOpen ? ' is-active' : ''}`}
|
|
263
|
-
aria-label="History"
|
|
264
|
-
onClick={onHistory}
|
|
265
|
-
>
|
|
266
|
-
{resolveIcon('history', { size: '1em' })}
|
|
267
|
-
</button>
|
|
268
|
-
<button
|
|
269
|
-
type="button"
|
|
270
|
-
className="velu-chatbot__iconbtn"
|
|
271
|
-
aria-label="New chat"
|
|
272
|
-
onClick={onNew}
|
|
273
|
-
>
|
|
274
|
-
{resolveIcon('plus', { size: '1em' })}
|
|
275
|
-
</button>
|
|
276
|
-
<button
|
|
277
|
-
type="button"
|
|
278
|
-
className="velu-chatbot__iconbtn"
|
|
279
|
-
aria-label="Close"
|
|
280
|
-
onClick={onClose}
|
|
281
|
-
>
|
|
282
|
-
{resolveIcon('x', { size: '1em' })}
|
|
283
|
-
</button>
|
|
284
|
-
</Cluster>
|
|
285
|
-
</Cluster>
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/* ── History overlay ────────────────────────────────────────────────── */
|
|
290
|
-
|
|
291
|
-
/* "Today, 10:24" / "Yesterday" / "Apr 28" for a conversation timestamp. */
|
|
292
|
-
function whenLabel(iso) {
|
|
293
|
-
if (!iso) return '';
|
|
294
|
-
const d = new Date(iso);
|
|
295
|
-
if (Number.isNaN(d.getTime())) return '';
|
|
296
|
-
const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
|
|
297
|
-
const days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
|
|
298
|
-
if (days <= 0) {
|
|
299
|
-
return `Today, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
|
300
|
-
}
|
|
301
|
-
if (days === 1) return 'Yesterday';
|
|
302
|
-
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function HistoryPanel({ items, loading, onPick, onClose }) {
|
|
306
|
-
return (
|
|
307
|
-
<div className="velu-chatbot__history">
|
|
308
|
-
<Cluster
|
|
309
|
-
space="var(--s-2)"
|
|
310
|
-
justify="space-between"
|
|
311
|
-
align="center"
|
|
312
|
-
className="velu-chatbot__history-head"
|
|
313
|
-
>
|
|
314
|
-
<span className="velu-chatbot__eyebrow">Recent chats</span>
|
|
315
|
-
<button
|
|
316
|
-
type="button"
|
|
317
|
-
className="velu-chatbot__iconbtn"
|
|
318
|
-
aria-label="Close history"
|
|
319
|
-
onClick={onClose}
|
|
320
|
-
>
|
|
321
|
-
{resolveIcon('x', { size: '1em' })}
|
|
322
|
-
</button>
|
|
323
|
-
</Cluster>
|
|
324
|
-
<Stack as="div" space="var(--s-5)" className="velu-chatbot__history-list">
|
|
325
|
-
{loading ? (
|
|
326
|
-
<span className="velu-chatbot__history-empty">Loading…</span>
|
|
327
|
-
) : items.length === 0 ? (
|
|
328
|
-
<span className="velu-chatbot__history-empty">No conversations yet.</span>
|
|
329
|
-
) : (
|
|
330
|
-
items.map((h) => (
|
|
331
|
-
<button
|
|
332
|
-
key={h.id}
|
|
333
|
-
type="button"
|
|
334
|
-
className="velu-chatbot__history-item"
|
|
335
|
-
onClick={() => onPick(h)}
|
|
336
|
-
>
|
|
337
|
-
<span className="velu-chatbot__history-item-title">{h.title}</span>
|
|
338
|
-
<span className="velu-chatbot__history-item-when">{h.when}</span>
|
|
339
|
-
</button>
|
|
340
|
-
))
|
|
341
|
-
)}
|
|
342
|
-
</Stack>
|
|
343
|
-
</div>
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/* ── Welcome / empty state ──────────────────────────────────────────── */
|
|
348
|
-
function Welcome({ onPick, suggestions }) {
|
|
349
|
-
// Site-specific starter questions (velu.json assistant.suggestions, or
|
|
350
|
-
// publish-time generated); the canned demo list is the dev-preview fallback.
|
|
351
|
-
const items = suggestions?.length
|
|
352
|
-
? suggestions.map((label) => ({ label, icon: 'sparkles' }))
|
|
353
|
-
: SUGGESTIONS;
|
|
354
|
-
return (
|
|
355
|
-
<Stack space="var(--s0)" className="velu-chatbot__welcome">
|
|
356
|
-
<p className="velu-chatbot__welcome-hi">Ask anything about the docs.</p>
|
|
357
|
-
<span className="velu-chatbot__eyebrow">Suggested</span>
|
|
358
|
-
<Stack space="var(--s-4)">
|
|
359
|
-
{items.map((s, i) => (
|
|
360
|
-
<button
|
|
361
|
-
key={i}
|
|
362
|
-
type="button"
|
|
363
|
-
className="velu-chatbot__suggest"
|
|
364
|
-
onClick={() => onPick(s.label)}
|
|
365
|
-
>
|
|
366
|
-
<Cluster space="var(--s-2)" align="center">
|
|
367
|
-
{resolveIcon(s.icon, { size: '1em' })}
|
|
368
|
-
<span>{s.label}</span>
|
|
369
|
-
</Cluster>
|
|
370
|
-
{resolveIcon('arrow-right', { size: '1em' })}
|
|
371
|
-
</button>
|
|
372
|
-
))}
|
|
373
|
-
</Stack>
|
|
374
|
-
</Stack>
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/* ── Messages ───────────────────────────────────────────────────────── */
|
|
379
|
-
function UserMsg({ text }) {
|
|
380
|
-
return (
|
|
381
|
-
<div className="velu-chatbot__msg velu-chatbot__msg--user">
|
|
382
|
-
<div className="velu-chatbot__bubble">{text}</div>
|
|
383
|
-
</div>
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function AiMsg({
|
|
388
|
-
tokens,
|
|
389
|
-
sources,
|
|
390
|
-
followups,
|
|
391
|
-
streaming,
|
|
392
|
-
onFollowup,
|
|
393
|
-
messageId,
|
|
394
|
-
onFeedback,
|
|
395
|
-
onNavigate,
|
|
396
|
-
}) {
|
|
397
|
-
const thinking = tokens.length === 0;
|
|
398
|
-
const [vote, setVote] = useState(null); // 'up' | 'down' | null
|
|
399
|
-
const [copied, setCopied] = useState(false);
|
|
400
|
-
|
|
401
|
-
const onAction = (ic) => {
|
|
402
|
-
if (ic === 'copy') {
|
|
403
|
-
try {
|
|
404
|
-
navigator.clipboard?.writeText(tokensToPlain(tokens));
|
|
405
|
-
setCopied(true);
|
|
406
|
-
setTimeout(() => setCopied(false), 1400);
|
|
407
|
-
} catch {
|
|
408
|
-
/* clipboard blocked — no-op */
|
|
409
|
-
}
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
if (ic === 'thumbs-up' || ic === 'thumbs-down') {
|
|
413
|
-
const v = ic === 'thumbs-up' ? 'up' : 'down';
|
|
414
|
-
const next = vote === v ? null : v; // toggle off if re-clicked
|
|
415
|
-
setVote(next);
|
|
416
|
-
onFeedback?.(messageId, next); // null = retract
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
return (
|
|
420
|
-
<div className="velu-chatbot__msg velu-chatbot__msg--ai">
|
|
421
|
-
<Cluster
|
|
422
|
-
space="var(--s-3)"
|
|
423
|
-
align="center"
|
|
424
|
-
className={`velu-chatbot__role${thinking ? ' is-thinking' : ''}`}
|
|
425
|
-
>
|
|
426
|
-
{thinking ? <BlocksWave /> : resolveIcon('sparkles', { size: '1em' })}
|
|
427
|
-
<span>{thinking ? 'Velu is thinking' : 'Velu'}</span>
|
|
428
|
-
</Cluster>
|
|
429
|
-
|
|
430
|
-
{!thinking && (
|
|
431
|
-
<div className="velu-chatbot__answer">
|
|
432
|
-
{renderStream(tokens, sources, onNavigate)}
|
|
433
|
-
{streaming && <span className="velu-chatbot__caret" />}
|
|
434
|
-
|
|
435
|
-
{!streaming && sources?.length > 0 && (
|
|
436
|
-
<Stack space="var(--s-4)" className="velu-chatbot__sources">
|
|
437
|
-
<span className="velu-chatbot__eyebrow">Sources</span>
|
|
438
|
-
{sources.map((s) => (
|
|
439
|
-
<a
|
|
440
|
-
key={s.num}
|
|
441
|
-
className="velu-chatbot__source"
|
|
442
|
-
href={sourceHref(s) || '#'}
|
|
443
|
-
onClick={(e) => onSourceClick(e, s, onNavigate)}
|
|
444
|
-
>
|
|
445
|
-
<span className="velu-chatbot__source-num">{s.num}</span>
|
|
446
|
-
<span className="velu-chatbot__source-meta">
|
|
447
|
-
<span className="velu-chatbot__source-title">{citeTitle(s)}</span>
|
|
448
|
-
<span className="velu-chatbot__source-path">{s.path}</span>
|
|
449
|
-
</span>
|
|
450
|
-
</a>
|
|
451
|
-
))}
|
|
452
|
-
</Stack>
|
|
453
|
-
)}
|
|
454
|
-
|
|
455
|
-
{!streaming && followups?.length > 0 && (
|
|
456
|
-
<Stack space="var(--s-4)" className="velu-chatbot__followups">
|
|
457
|
-
<span className="velu-chatbot__eyebrow">Follow up</span>
|
|
458
|
-
{followups.map((f, i) => (
|
|
459
|
-
<button
|
|
460
|
-
key={i}
|
|
461
|
-
type="button"
|
|
462
|
-
className="velu-chatbot__followup"
|
|
463
|
-
onClick={() => onFollowup(f)}
|
|
464
|
-
>
|
|
465
|
-
<span>{f}</span>
|
|
466
|
-
{resolveIcon('arrow-right', { size: '1em' })}
|
|
467
|
-
</button>
|
|
468
|
-
))}
|
|
469
|
-
</Stack>
|
|
470
|
-
)}
|
|
471
|
-
</div>
|
|
472
|
-
)}
|
|
473
|
-
|
|
474
|
-
{!streaming && !thinking && (
|
|
475
|
-
<Cluster space="0" className="velu-chatbot__msg-actions">
|
|
476
|
-
{['copy', 'thumbs-up', 'thumbs-down'].map((ic) => {
|
|
477
|
-
const active =
|
|
478
|
-
(ic === 'thumbs-up' && vote === 'up') ||
|
|
479
|
-
(ic === 'thumbs-down' && vote === 'down');
|
|
480
|
-
const label = ic === 'copy' && copied ? 'check' : ic;
|
|
481
|
-
return (
|
|
482
|
-
<button
|
|
483
|
-
key={ic}
|
|
484
|
-
type="button"
|
|
485
|
-
className={`velu-chatbot__msg-action${active ? ' is-active' : ''}`}
|
|
486
|
-
aria-label={ic}
|
|
487
|
-
aria-pressed={ic === 'copy' ? undefined : active}
|
|
488
|
-
onClick={() => onAction(ic)}
|
|
489
|
-
>
|
|
490
|
-
{resolveIcon(label, { size: '1em' })}
|
|
491
|
-
</button>
|
|
492
|
-
);
|
|
493
|
-
})}
|
|
494
|
-
</Cluster>
|
|
495
|
-
)}
|
|
496
|
-
</div>
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/* ── Composer ───────────────────────────────────────────────────────── */
|
|
501
|
-
function Composer({ value, onChange, onSubmit, disabled }) {
|
|
502
|
-
const taRef = useRef(null);
|
|
503
|
-
useEffect(() => {
|
|
504
|
-
const ta = taRef.current;
|
|
505
|
-
if (!ta) return;
|
|
506
|
-
ta.style.height = 'auto';
|
|
507
|
-
ta.style.height = `${ta.scrollHeight}px`;
|
|
508
|
-
}, [value]);
|
|
509
|
-
|
|
510
|
-
const active = value.trim().length > 0 && !disabled;
|
|
511
|
-
const onKeyDown = (e) => {
|
|
512
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
513
|
-
e.preventDefault();
|
|
514
|
-
if (active) onSubmit();
|
|
515
|
-
}
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
return (
|
|
519
|
-
<div className="velu-chatbot__composer">
|
|
520
|
-
<div className="velu-chatbot__input-wrap">
|
|
521
|
-
<textarea
|
|
522
|
-
ref={taRef}
|
|
523
|
-
className="velu-chatbot__input"
|
|
524
|
-
placeholder="Ask anything about the docs…"
|
|
525
|
-
rows={1}
|
|
526
|
-
value={value}
|
|
527
|
-
onChange={(e) => onChange(e.target.value)}
|
|
528
|
-
onKeyDown={onKeyDown}
|
|
529
|
-
/>
|
|
530
|
-
<button
|
|
531
|
-
type="button"
|
|
532
|
-
className={`velu-chatbot__send${active ? ' is-active' : ''}`}
|
|
533
|
-
onClick={onSubmit}
|
|
534
|
-
disabled={!active}
|
|
535
|
-
aria-label="Send"
|
|
536
|
-
>
|
|
537
|
-
{resolveIcon('arrow-up', { size: '1em' })}
|
|
538
|
-
</button>
|
|
539
|
-
</div>
|
|
540
|
-
<div className="velu-chatbot__foot">
|
|
541
|
-
<kbd>Enter</kbd> to send · <kbd>⇧ Enter</kbd> for newline
|
|
542
|
-
</div>
|
|
543
|
-
</div>
|
|
544
|
-
);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/* ── Panel ──────────────────────────────────────────────────────────── */
|
|
548
|
-
export default function Chatbot({
|
|
549
|
-
open = false,
|
|
550
|
-
seedQuestion,
|
|
551
|
-
onClose,
|
|
552
|
-
ask,
|
|
553
|
-
onFeedback,
|
|
554
|
-
onNavigate,
|
|
555
|
-
listHistory,
|
|
556
|
-
loadConversation,
|
|
557
|
-
suggestions,
|
|
558
|
-
className = '',
|
|
559
|
-
...rest
|
|
560
|
-
}) {
|
|
561
|
-
const [messages, setMessages] = useState([]);
|
|
562
|
-
const [input, setInput] = useState('');
|
|
563
|
-
const [historyOpen, setHistoryOpen] = useState(false);
|
|
564
|
-
const [history, setHistory] = useState([]);
|
|
565
|
-
const [historyLoading, setHistoryLoading] = useState(false);
|
|
566
|
-
const bodyRef = useRef(null);
|
|
567
|
-
const streamingRef = useRef(false);
|
|
568
|
-
const lastSeedRef = useRef(undefined);
|
|
569
|
-
// Real-backend session: conversation id threads turns; the controller lets
|
|
570
|
-
// a new-chat / close / unmount abort an in-flight stream.
|
|
571
|
-
const convoRef = useRef(undefined);
|
|
572
|
-
const abortRef = useRef(null);
|
|
573
|
-
|
|
574
|
-
useEffect(() => {
|
|
575
|
-
const el = bodyRef.current;
|
|
576
|
-
if (el) el.scrollTop = el.scrollHeight;
|
|
577
|
-
}, [messages]);
|
|
578
|
-
|
|
579
|
-
// Merge a patch into the last (assistant) message.
|
|
580
|
-
const patchLast = useCallback((patch) => {
|
|
581
|
-
setMessages((prev) => {
|
|
582
|
-
if (!prev.length) return prev;
|
|
583
|
-
const out = prev.slice();
|
|
584
|
-
out[out.length - 1] = { ...out[out.length - 1], ...patch };
|
|
585
|
-
return out;
|
|
586
|
-
});
|
|
587
|
-
}, []);
|
|
588
|
-
|
|
589
|
-
// Stop whatever is in flight — a canned setTimeout chain ({ timer }) or a
|
|
590
|
-
// real fetch stream (an AbortController). Safe to call any time.
|
|
591
|
-
const cancelInFlight = useCallback(() => {
|
|
592
|
-
const a = abortRef.current;
|
|
593
|
-
if (a) {
|
|
594
|
-
if (a.timer) clearTimeout(a.timer);
|
|
595
|
-
if (typeof a.abort === 'function') a.abort();
|
|
596
|
-
}
|
|
597
|
-
abortRef.current = null;
|
|
598
|
-
streamingRef.current = false;
|
|
599
|
-
}, []);
|
|
600
|
-
|
|
601
|
-
// Real history: fetch the visitor's conversations when the panel opens.
|
|
602
|
-
// Without a backend (dev preview) the canned demo entries show instead.
|
|
603
|
-
useEffect(() => {
|
|
604
|
-
if (!historyOpen) return;
|
|
605
|
-
if (!listHistory) {
|
|
606
|
-
setHistory(HISTORY.map((h) => ({ ...h, demo: true })));
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
let stale = false;
|
|
610
|
-
setHistoryLoading(true);
|
|
611
|
-
listHistory()
|
|
612
|
-
.then((items) => {
|
|
613
|
-
if (stale) return;
|
|
614
|
-
setHistory(items.map((c) => ({
|
|
615
|
-
id: c.conversationId,
|
|
616
|
-
title: c.title,
|
|
617
|
-
when: whenLabel(c.updatedAt),
|
|
618
|
-
conversation: c,
|
|
619
|
-
})));
|
|
620
|
-
})
|
|
621
|
-
.catch(() => { if (!stale) setHistory([]); })
|
|
622
|
-
.finally(() => { if (!stale) setHistoryLoading(false); });
|
|
623
|
-
return () => { stale = true; };
|
|
624
|
-
}, [historyOpen, listHistory]);
|
|
625
|
-
|
|
626
|
-
// Resume a past conversation: load its turns into the panel and thread the
|
|
627
|
-
// conversation id so follow-up asks continue it.
|
|
628
|
-
const openConversation = useCallback(
|
|
629
|
-
async (item) => {
|
|
630
|
-
setHistoryOpen(false);
|
|
631
|
-
if (!item.conversation || !loadConversation) return; // demo entry
|
|
632
|
-
cancelInFlight();
|
|
633
|
-
try {
|
|
634
|
-
const { conversationId, messages: msgs } = await loadConversation(item.conversation);
|
|
635
|
-
convoRef.current = conversationId;
|
|
636
|
-
setMessages(msgs.map((m) =>
|
|
637
|
-
m.role === 'user'
|
|
638
|
-
? { role: 'user', text: m.content }
|
|
639
|
-
: {
|
|
640
|
-
role: 'ai',
|
|
641
|
-
tokens: textToTokens(m.content),
|
|
642
|
-
sources: m.citations || [],
|
|
643
|
-
followups: [],
|
|
644
|
-
streaming: false,
|
|
645
|
-
messageId: m.messageId,
|
|
646
|
-
},
|
|
647
|
-
));
|
|
648
|
-
} catch {
|
|
649
|
-
/* keep the current chat if the load fails */
|
|
650
|
-
}
|
|
651
|
-
},
|
|
652
|
-
[loadConversation, cancelInFlight],
|
|
653
|
-
);
|
|
654
|
-
|
|
655
|
-
const send = useCallback(
|
|
656
|
-
async (text) => {
|
|
657
|
-
const prompt = String(text ?? '').trim();
|
|
658
|
-
if (!prompt || streamingRef.current) return;
|
|
659
|
-
setInput('');
|
|
660
|
-
|
|
661
|
-
// ── No backend wired → canned, fake-streamed demo answer ──────────
|
|
662
|
-
if (!ask) {
|
|
663
|
-
const answer = buildAnswer();
|
|
664
|
-
const fullTokens = tokenizeAnswer(answer);
|
|
665
|
-
setMessages((prev) => [
|
|
666
|
-
...prev,
|
|
667
|
-
{ role: 'user', text: prompt },
|
|
668
|
-
{
|
|
669
|
-
role: 'ai',
|
|
670
|
-
tokens: [],
|
|
671
|
-
sources: answer.sources,
|
|
672
|
-
followups: answer.followups,
|
|
673
|
-
streaming: true,
|
|
674
|
-
},
|
|
675
|
-
]);
|
|
676
|
-
streamingRef.current = true;
|
|
677
|
-
let i = 0;
|
|
678
|
-
const step = () => {
|
|
679
|
-
i = Math.min(fullTokens.length, i + 1);
|
|
680
|
-
patchLast({
|
|
681
|
-
tokens: fullTokens.slice(0, i),
|
|
682
|
-
streaming: i < fullTokens.length,
|
|
683
|
-
});
|
|
684
|
-
if (i < fullTokens.length) {
|
|
685
|
-
abortRef.current = { timer: setTimeout(step, 28) };
|
|
686
|
-
} else {
|
|
687
|
-
streamingRef.current = false;
|
|
688
|
-
abortRef.current = null;
|
|
689
|
-
}
|
|
690
|
-
};
|
|
691
|
-
abortRef.current = { timer: setTimeout(step, 1400) }; // "thinking" pause
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// ── Real backend → stream deltas + citations from `ask` ───────────
|
|
696
|
-
setMessages((prev) => [
|
|
697
|
-
...prev,
|
|
698
|
-
{ role: 'user', text: prompt },
|
|
699
|
-
{ role: 'ai', tokens: [], sources: [], followups: [], streaming: true },
|
|
700
|
-
]);
|
|
701
|
-
streamingRef.current = true;
|
|
702
|
-
const controller =
|
|
703
|
-
typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
704
|
-
abortRef.current = controller;
|
|
705
|
-
|
|
706
|
-
let acc = '';
|
|
707
|
-
let sources = [];
|
|
708
|
-
let messageId;
|
|
709
|
-
try {
|
|
710
|
-
for await (const ev of ask(prompt, {
|
|
711
|
-
conversationId: convoRef.current,
|
|
712
|
-
signal: controller?.signal,
|
|
713
|
-
})) {
|
|
714
|
-
if (ev.conversationId) convoRef.current = ev.conversationId;
|
|
715
|
-
if (ev.messageId) messageId = ev.messageId;
|
|
716
|
-
if (ev.delta) acc += ev.delta;
|
|
717
|
-
if (ev.citations) {
|
|
718
|
-
sources = ev.citations.map((c, idx) => ({
|
|
719
|
-
num: c.num ?? idx + 1,
|
|
720
|
-
title: c.title,
|
|
721
|
-
path: c.path ?? c.route_path,
|
|
722
|
-
url: c.url,
|
|
723
|
-
}));
|
|
724
|
-
}
|
|
725
|
-
patchLast({ tokens: textToTokens(acc), sources, streaming: true, messageId });
|
|
726
|
-
}
|
|
727
|
-
patchLast({ tokens: textToTokens(acc), sources, streaming: false, messageId });
|
|
728
|
-
} catch (err) {
|
|
729
|
-
if (controller?.signal?.aborted) {
|
|
730
|
-
patchLast({ streaming: false });
|
|
731
|
-
} else {
|
|
732
|
-
patchLast({
|
|
733
|
-
tokens: textToTokens(
|
|
734
|
-
acc || 'Sorry — I couldn’t reach the assistant. Please try again.'
|
|
735
|
-
),
|
|
736
|
-
streaming: false,
|
|
737
|
-
error: true,
|
|
738
|
-
});
|
|
739
|
-
}
|
|
740
|
-
} finally {
|
|
741
|
-
streamingRef.current = false;
|
|
742
|
-
abortRef.current = null;
|
|
743
|
-
}
|
|
744
|
-
},
|
|
745
|
-
[ask, patchLast]
|
|
746
|
-
);
|
|
747
|
-
|
|
748
|
-
// Abort any in-flight stream when the panel unmounts.
|
|
749
|
-
useEffect(() => () => cancelInFlight(), [cancelInFlight]);
|
|
750
|
-
|
|
751
|
-
// A new seedQuestion (from the page's AskBar) sends the first message.
|
|
752
|
-
useEffect(() => {
|
|
753
|
-
if (open && seedQuestion && seedQuestion !== lastSeedRef.current) {
|
|
754
|
-
lastSeedRef.current = seedQuestion;
|
|
755
|
-
send(seedQuestion);
|
|
756
|
-
}
|
|
757
|
-
}, [open, seedQuestion, send]);
|
|
758
|
-
|
|
759
|
-
const newChat = () => {
|
|
760
|
-
cancelInFlight();
|
|
761
|
-
convoRef.current = undefined; // fresh conversation thread
|
|
762
|
-
setMessages([]);
|
|
763
|
-
setInput('');
|
|
764
|
-
setHistoryOpen(false);
|
|
765
|
-
};
|
|
766
|
-
|
|
767
|
-
// Drag-to-dismiss (mobile bottom-sheet only). Pointer events are
|
|
768
|
-
// attached to the drag-handle element below; the handle's pointer
|
|
769
|
-
// capture keeps the events flowing even if the finger slides off
|
|
770
|
-
// the handle into the body. While dragging we apply an inline
|
|
771
|
-
// `transform: translateY(...)` to the panel, overriding the CSS-
|
|
772
|
-
// controlled transform. On release, if the drag distance exceeded
|
|
773
|
-
// the threshold we call onClose() and the panel slides the rest of
|
|
774
|
-
// the way down via the CSS transition; otherwise we drop the
|
|
775
|
-
// inline transform and the panel snaps back via the same
|
|
776
|
-
// transition.
|
|
777
|
-
const dragRef = useRef({ active: false, startY: 0, currentY: 0 });
|
|
778
|
-
const [dragOffset, setDragOffset] = useState(0);
|
|
779
|
-
// Drag-to-dismiss is active wherever the chatbot is rendered as a
|
|
780
|
-
// bottom sheet — same threshold as the @container query in
|
|
781
|
-
// chatbot.css (< 1024px covers both narrow / tablet and mobile).
|
|
782
|
-
const isBottomSheet = () =>
|
|
783
|
-
typeof window !== 'undefined' &&
|
|
784
|
-
window.matchMedia('(max-width: 1024px)').matches;
|
|
785
|
-
const onDragStart = (e) => {
|
|
786
|
-
if (!isBottomSheet()) return;
|
|
787
|
-
dragRef.current = { active: true, startY: e.clientY, currentY: 0 };
|
|
788
|
-
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
789
|
-
};
|
|
790
|
-
const onDragMove = (e) => {
|
|
791
|
-
if (!dragRef.current.active) return;
|
|
792
|
-
const delta = Math.max(0, e.clientY - dragRef.current.startY);
|
|
793
|
-
dragRef.current.currentY = delta;
|
|
794
|
-
setDragOffset(delta);
|
|
795
|
-
};
|
|
796
|
-
const onDragEnd = () => {
|
|
797
|
-
if (!dragRef.current.active) return;
|
|
798
|
-
dragRef.current.active = false;
|
|
799
|
-
const shouldClose = dragRef.current.currentY > 80;
|
|
800
|
-
setDragOffset(0);
|
|
801
|
-
if (shouldClose) onClose?.();
|
|
802
|
-
};
|
|
803
|
-
// Reset any residual drag offset whenever the panel closes from
|
|
804
|
-
// outside (scrim click, X button, etc.) so a re-open starts clean.
|
|
805
|
-
useEffect(() => {
|
|
806
|
-
if (!open) setDragOffset(0);
|
|
807
|
-
}, [open]);
|
|
808
|
-
|
|
809
|
-
const cls = `velu-chatbot${open ? ' velu-chatbot--open' : ''} ${className}`.trim();
|
|
810
|
-
const dragStyle =
|
|
811
|
-
dragOffset > 0
|
|
812
|
-
? { transform: `translateY(${dragOffset}px)`, transition: 'none' }
|
|
813
|
-
: undefined;
|
|
814
|
-
|
|
815
|
-
return (
|
|
816
|
-
<aside
|
|
817
|
-
className={cls}
|
|
818
|
-
aria-hidden={!open}
|
|
819
|
-
aria-label="Ask AI"
|
|
820
|
-
style={dragStyle}
|
|
821
|
-
{...rest}
|
|
822
|
-
>
|
|
823
|
-
{/* Drag handle — mobile only (CSS in chatbot.css). The pill
|
|
824
|
-
visual + the touch-capturing strip across the top of the
|
|
825
|
-
sheet. Pointer events here drive the drag-to-dismiss
|
|
826
|
-
gesture above. */}
|
|
827
|
-
<div
|
|
828
|
-
className="velu-chatbot__drag-handle"
|
|
829
|
-
onPointerDown={onDragStart}
|
|
830
|
-
onPointerMove={onDragMove}
|
|
831
|
-
onPointerUp={onDragEnd}
|
|
832
|
-
onPointerCancel={onDragEnd}
|
|
833
|
-
aria-hidden="true"
|
|
834
|
-
/>
|
|
835
|
-
<ChatHeader
|
|
836
|
-
onClose={onClose}
|
|
837
|
-
onNew={newChat}
|
|
838
|
-
onHistory={() => setHistoryOpen((o) => !o)}
|
|
839
|
-
historyOpen={historyOpen}
|
|
840
|
-
/>
|
|
841
|
-
|
|
842
|
-
{historyOpen && (
|
|
843
|
-
<HistoryPanel
|
|
844
|
-
items={history}
|
|
845
|
-
loading={historyLoading}
|
|
846
|
-
onClose={() => setHistoryOpen(false)}
|
|
847
|
-
onPick={openConversation}
|
|
848
|
-
/>
|
|
849
|
-
)}
|
|
850
|
-
|
|
851
|
-
<div className="velu-chatbot__body" ref={bodyRef}>
|
|
852
|
-
{messages.length === 0 ? (
|
|
853
|
-
<Welcome onPick={(t) => send(t)} suggestions={suggestions} />
|
|
854
|
-
) : (
|
|
855
|
-
<Stack space="var(--s1)">
|
|
856
|
-
{messages.map((m, i) =>
|
|
857
|
-
m.role === 'user' ? (
|
|
858
|
-
<UserMsg key={i} text={m.text} />
|
|
859
|
-
) : (
|
|
860
|
-
<AiMsg
|
|
861
|
-
key={i}
|
|
862
|
-
tokens={m.tokens}
|
|
863
|
-
sources={m.sources}
|
|
864
|
-
followups={m.followups}
|
|
865
|
-
streaming={m.streaming}
|
|
866
|
-
messageId={m.messageId}
|
|
867
|
-
onFeedback={onFeedback}
|
|
868
|
-
onNavigate={onNavigate}
|
|
869
|
-
onFollowup={(f) => send(f)}
|
|
870
|
-
/>
|
|
871
|
-
),
|
|
872
|
-
)}
|
|
873
|
-
</Stack>
|
|
874
|
-
)}
|
|
875
|
-
</div>
|
|
876
|
-
|
|
877
|
-
<Composer
|
|
878
|
-
value={input}
|
|
879
|
-
onChange={setInput}
|
|
880
|
-
onSubmit={() => send(input)}
|
|
881
|
-
disabled={streamingRef.current}
|
|
882
|
-
/>
|
|
883
|
-
</aside>
|
|
884
|
-
);
|
|
885
|
-
}
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
3
|
+
import Stack from '../primitives/Stack.jsx';
|
|
4
|
+
import Cluster from '../primitives/Cluster.jsx';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Chatbot — the Velu "Ask AI" panel. A right-side slide-out: header +
|
|
8
|
+
* scrolling message body + composer. Ported from the Claude-Design
|
|
9
|
+
* "Velu Chatbot" handoff (rich variant), retokenized onto velu-ui's
|
|
10
|
+
* scale so it themes for free via [data-theme].
|
|
11
|
+
*
|
|
12
|
+
* <Chatbot
|
|
13
|
+
* open={chatOpen}
|
|
14
|
+
* seedQuestion={question} // first message — sent on open
|
|
15
|
+
* onClose={() => setChatOpen(false)}
|
|
16
|
+
* ask={askDocs} // OPTIONAL — real backend (see below)
|
|
17
|
+
* onFeedback={(id, v) => …} // OPTIONAL — 👍/👎 for a message
|
|
18
|
+
* />
|
|
19
|
+
*
|
|
20
|
+
* Slide: the panel is fixed to the inline-end edge; `open` toggles a
|
|
21
|
+
* `translateX` between fully off-screen and 0. Width is the
|
|
22
|
+
* `--vchat-width` variable (default 24rem ≈ the design's 384px).
|
|
23
|
+
*
|
|
24
|
+
* Backend seam (mirrors Search.jsx's injectable `search` prop):
|
|
25
|
+
* ask(prompt, { conversationId, signal }) → AsyncIterable<AskEvent>
|
|
26
|
+
* AskEvent = {
|
|
27
|
+
* delta?: string, // text chunk to append (streamed)
|
|
28
|
+
* citations?: Source[], // [{ num, title, path, url }] — replaces sources
|
|
29
|
+
* messageId?: string, // assistant message id (for feedback)
|
|
30
|
+
* conversationId?: string, // threaded across turns
|
|
31
|
+
* }
|
|
32
|
+
* When `ask` is omitted the panel falls back to a canned, fake-streamed
|
|
33
|
+
* demo answer (so dev preview / offline still shows the experience).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/* ── Brand mark — the Velu double-hook logo ─────────────────────────── */
|
|
37
|
+
// `size` is the glyph height; width derives from the 32:24 viewBox.
|
|
38
|
+
function VeluMark({ size = 'var(--icon-size-lg)' }) {
|
|
39
|
+
return (
|
|
40
|
+
<svg
|
|
41
|
+
className="velu-chatbot__mark"
|
|
42
|
+
viewBox="0 0 32 24"
|
|
43
|
+
fill="currentColor"
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
style={{ width: `calc(${size} * (32 / 24))`, height: size }}
|
|
46
|
+
>
|
|
47
|
+
<path d="M 29.656 14.153 C 29.574 14.562 29.351 14.929 29.025 15.193 C 28.699 15.457 28.291 15.601 27.869 15.601 L 21.37 15.601 C 20.534 15.601 19.805 16.163 19.603 16.964 L 18.167 22.637 C 18.068 23.026 17.841 23.372 17.521 23.619 C 17.2 23.866 16.805 24 16.399 24 L 10.438 24 C 10.161 24 9.888 23.938 9.639 23.818 C 9.39 23.698 9.172 23.524 9.001 23.308 C 8.831 23.092 8.712 22.841 8.655 22.574 C 8.598 22.306 8.603 22.029 8.67 21.764 L 10.189 15.764 C 10.287 15.374 10.515 15.029 10.835 14.782 C 11.155 14.535 11.55 14.401 11.956 14.4 L 18.393 14.4 C 19.262 14.4 20.01 13.795 20.18 12.953 L 22.388 2.048 C 22.471 1.639 22.694 1.272 23.019 1.008 C 23.345 0.744 23.754 0.6 24.175 0.6 L 30.177 0.6 C 30.447 0.6 30.713 0.659 30.957 0.773 C 31.201 0.886 31.416 1.052 31.587 1.258 C 31.758 1.464 31.881 1.705 31.946 1.964 C 32.011 2.222 32.017 2.492 31.964 2.753 L 29.656 14.153 Z M 9.611 13.554 C 9.528 13.962 9.305 14.329 8.979 14.593 C 8.653 14.857 8.245 15.001 7.824 15.001 L 1.823 15.001 C 1.553 15.001 1.287 14.942 1.043 14.828 C 0.799 14.714 0.584 14.548 0.413 14.342 C 0.242 14.136 0.119 13.895 0.054 13.637 C -0.011 13.378 -0.017 13.109 0.036 12.848 L 2.344 1.447 C 2.426 1.039 2.649 0.672 2.975 0.408 C 3.301 0.144 3.709 0 4.131 0 L 10.132 0 C 10.402 0 10.668 0.059 10.912 0.173 C 11.155 0.287 11.371 0.453 11.541 0.659 C 11.712 0.865 11.835 1.106 11.9 1.364 C 11.965 1.623 11.972 1.892 11.919 2.153 L 9.611 13.553 L 9.611 13.554 Z" />
|
|
48
|
+
</svg>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ── "Thinking" loader — 3x3 grid of squares, opacity wave sweeps the
|
|
53
|
+
anti-diagonal so it ripples top-left → bottom-right. ─────────────── */
|
|
54
|
+
function BlocksWave() {
|
|
55
|
+
const cells = [
|
|
56
|
+
[1, 1, 0], [8.33, 1, 1], [15.66, 1, 2],
|
|
57
|
+
[1, 8.33, 1], [8.33, 8.33, 2], [15.66, 8.33, 3],
|
|
58
|
+
[1, 15.66, 2], [8.33, 15.66, 3], [15.66, 15.66, 4],
|
|
59
|
+
];
|
|
60
|
+
return (
|
|
61
|
+
<svg className="velu-chatbot__wave" viewBox="0 0 24 24" aria-hidden="true">
|
|
62
|
+
{cells.map(([x, y, cell], i) => (
|
|
63
|
+
<rect key={i} x={x} y={y} width="7.33" height="7.33" data-cell={cell} />
|
|
64
|
+
))}
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ── Demo data ──────────────────────────────────────────────────────── */
|
|
70
|
+
const SUGGESTIONS = [
|
|
71
|
+
{ label: 'How do I set up GitHub sync?', icon: 'github' },
|
|
72
|
+
{ label: 'Customize the look of my docs site', icon: 'book' },
|
|
73
|
+
{ label: 'Configure AI search ranking', icon: 'sparkles' },
|
|
74
|
+
{ label: 'Quickstart in under five minutes', icon: 'rocket' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const HISTORY = [
|
|
78
|
+
{ id: 'h1', title: 'Setting up GitHub sync', when: 'Today, 10:24' },
|
|
79
|
+
{ id: 'h2', title: 'Custom theme tokens & dark mode', when: 'Today, 09:11' },
|
|
80
|
+
{ id: 'h3', title: 'Configure AI search ranking', when: 'Yesterday' },
|
|
81
|
+
{ id: 'h4', title: 'Embed a Mermaid diagram in MDX', when: 'Yesterday' },
|
|
82
|
+
{ id: 'h5', title: 'Why are my callouts missing icons?', when: 'Apr 28' },
|
|
83
|
+
{ id: 'h6', title: 'Quickstart — minimum site config', when: 'Apr 27' },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const SOURCES = [
|
|
87
|
+
{ num: 1, title: 'GitHub & GitLab Sync', path: 'docs/integrations/github-sync.md' },
|
|
88
|
+
{ num: 2, title: 'Site Settings — Repositories', path: 'docs/your-docs-site/site-settings.md' },
|
|
89
|
+
{ num: 3, title: 'AI-native Documentation', path: 'docs/get-started/ai-native.md' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/* Canned answer — segments interleave text, inline `code`, and citation
|
|
93
|
+
markers. The same answer is returned for any prompt (demo). */
|
|
94
|
+
function buildAnswer() {
|
|
95
|
+
return {
|
|
96
|
+
segments: [
|
|
97
|
+
{ t: 'Velu syncs documentation directly with GitHub and GitLab — every push to a connected repository updates the live docs site within seconds.' },
|
|
98
|
+
{ cite: 1 },
|
|
99
|
+
{ t: ' To set it up:\n\n1. Open ' },
|
|
100
|
+
{ code: 'Site Settings → Repositories' },
|
|
101
|
+
{ t: ' and click ' },
|
|
102
|
+
{ code: 'Connect repository' },
|
|
103
|
+
{ t: '.\n2. Authorize the Velu app on the org that owns the repo.\n3. Pick a branch (default ' },
|
|
104
|
+
{ code: 'main' },
|
|
105
|
+
{ t: ') and a docs root path.' },
|
|
106
|
+
{ cite: 2 },
|
|
107
|
+
{ t: '\n\nVelu watches changes to ' },
|
|
108
|
+
{ code: '.md' },
|
|
109
|
+
{ t: ' and ' },
|
|
110
|
+
{ code: '.mdx' },
|
|
111
|
+
{ t: ' files and re-indexes the site automatically.' },
|
|
112
|
+
{ cite: 3 },
|
|
113
|
+
],
|
|
114
|
+
sources: SOURCES,
|
|
115
|
+
followups: [
|
|
116
|
+
'How do I preview pull-request changes?',
|
|
117
|
+
'Can I sync from a monorepo subdirectory?',
|
|
118
|
+
'What about private repositories?',
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Real-backend path: turn the accumulated answer TEXT into the same token
|
|
124
|
+
shape `renderStream` consumes. The assistant streams plain text with inline
|
|
125
|
+
`code` spans and bracketed citation markers ([1], [2]); split those out so
|
|
126
|
+
they render as <code> and citation chips, exactly like the canned path. */
|
|
127
|
+
function textToTokens(text) {
|
|
128
|
+
const tokens = [];
|
|
129
|
+
const re = /(`[^`]*`|\[\d+\])/g;
|
|
130
|
+
let last = 0;
|
|
131
|
+
let m;
|
|
132
|
+
while ((m = re.exec(text)) !== null) {
|
|
133
|
+
if (m.index > last) tokens.push({ kind: 't', v: text.slice(last, m.index) });
|
|
134
|
+
const tok = m[0];
|
|
135
|
+
if (tok[0] === '`') tokens.push({ kind: 'code', v: tok.slice(1, -1) });
|
|
136
|
+
else tokens.push({ kind: 'cite', n: Number(tok.slice(1, -1)) });
|
|
137
|
+
last = re.lastIndex;
|
|
138
|
+
}
|
|
139
|
+
if (last < text.length) tokens.push({ kind: 't', v: text.slice(last) });
|
|
140
|
+
return tokens;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Plain-text of an answer (for the Copy action). */
|
|
144
|
+
function tokensToPlain(tokens) {
|
|
145
|
+
return tokens
|
|
146
|
+
.map((tk) => (tk.kind === 'cite' ? `[${tk.n}]` : tk.v))
|
|
147
|
+
.join('')
|
|
148
|
+
.trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Flatten an answer into a stream of small tokens for incremental render. */
|
|
152
|
+
function tokenizeAnswer(answer) {
|
|
153
|
+
const tokens = [];
|
|
154
|
+
for (const seg of answer.segments) {
|
|
155
|
+
if (seg.cite != null) { tokens.push({ kind: 'cite', n: seg.cite }); continue; }
|
|
156
|
+
if (seg.code != null) { tokens.push({ kind: 'code', v: seg.code }); continue; }
|
|
157
|
+
const parts = seg.t.match(/\S+\s*|\s+/g) || [seg.t];
|
|
158
|
+
for (const p of parts) tokens.push({ kind: 't', v: p });
|
|
159
|
+
}
|
|
160
|
+
return tokens;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* A citation's in-site target. Real backend sources carry a `url` (and `path`
|
|
164
|
+
is a route like "/essentials/code"); the demo's `path` is a fake file path,
|
|
165
|
+
so those stay inert. */
|
|
166
|
+
function sourceHref(s) {
|
|
167
|
+
if (s?.url) return s.url;
|
|
168
|
+
if (s?.path && s.path.startsWith('/')) return s.path;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* A readable label for a source: the section heading if present, else a title
|
|
173
|
+
derived from the route (e.g. "/essentials/settings" → "Settings"). */
|
|
174
|
+
function citeTitle(s) {
|
|
175
|
+
if (s?.title) return s.title;
|
|
176
|
+
const path = (s?.path || '').replace(/[#?].*$/, '');
|
|
177
|
+
const seg = path.split('/').filter(Boolean).pop();
|
|
178
|
+
if (!seg) return 'Documentation';
|
|
179
|
+
return seg.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Source click: SPA-navigate same-origin targets via onNavigate (keeps the chat
|
|
183
|
+
panel open + scrolls to the heading); otherwise let the <a href> navigate. */
|
|
184
|
+
function onSourceClick(e, s, onNavigate) {
|
|
185
|
+
const href = sourceHref(s);
|
|
186
|
+
if (!href) { e.preventDefault(); return; }
|
|
187
|
+
if (!onNavigate) return; // no SPA hook → let the browser follow the href
|
|
188
|
+
try {
|
|
189
|
+
const u = new URL(href, window.location.origin);
|
|
190
|
+
if (u.origin === window.location.origin) {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
onNavigate(u.pathname + u.search + u.hash);
|
|
193
|
+
}
|
|
194
|
+
} catch { /* malformed — fall back to href */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* Render partial tokens as paragraphs (split on \n\n, single \n → <br>). */
|
|
198
|
+
function renderStream(tokens, sources, onNavigate) {
|
|
199
|
+
const paragraphs = [[]];
|
|
200
|
+
for (const tk of tokens) {
|
|
201
|
+
if (tk.kind === 't' && tk.v.includes('\n\n')) {
|
|
202
|
+
const split = tk.v.split('\n\n');
|
|
203
|
+
paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[0] });
|
|
204
|
+
for (let i = 1; i < split.length; i++) {
|
|
205
|
+
paragraphs.push([]);
|
|
206
|
+
if (split[i]) paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[i] });
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
paragraphs[paragraphs.length - 1].push(tk);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return paragraphs.map((para, pi) => (
|
|
213
|
+
<p key={pi}>
|
|
214
|
+
{para.map((tk, i) => {
|
|
215
|
+
if (tk.kind === 'cite') {
|
|
216
|
+
const src = sources?.find((s) => s.num === tk.n);
|
|
217
|
+
return (
|
|
218
|
+
<a
|
|
219
|
+
key={i}
|
|
220
|
+
className="velu-chatbot__cite"
|
|
221
|
+
href={sourceHref(src) || '#'}
|
|
222
|
+
onClick={(e) => onSourceClick(e, src, onNavigate)}
|
|
223
|
+
title={citeTitle(src)}
|
|
224
|
+
>
|
|
225
|
+
{tk.n}
|
|
226
|
+
</a>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (tk.kind === 'code') return <code key={i}>{tk.v}</code>;
|
|
230
|
+
const lines = tk.v.split('\n');
|
|
231
|
+
return lines.map((line, li) => (
|
|
232
|
+
<React.Fragment key={`${i}-${li}`}>
|
|
233
|
+
{li > 0 && <br />}
|
|
234
|
+
{line}
|
|
235
|
+
</React.Fragment>
|
|
236
|
+
));
|
|
237
|
+
})}
|
|
238
|
+
</p>
|
|
239
|
+
));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ── Header ─────────────────────────────────────────────────────────── */
|
|
243
|
+
function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
|
|
244
|
+
return (
|
|
245
|
+
<Cluster
|
|
246
|
+
space="var(--s-2)"
|
|
247
|
+
justify="space-between"
|
|
248
|
+
align="center"
|
|
249
|
+
className="velu-chatbot__header"
|
|
250
|
+
>
|
|
251
|
+
<Cluster space="var(--s-2)" align="center">
|
|
252
|
+
<VeluMark />
|
|
253
|
+
<span className="velu-chatbot__brand">
|
|
254
|
+
<span className="velu-chatbot__brand-name">Velu</span>
|
|
255
|
+
<span className="velu-chatbot__brand-sep">/</span>
|
|
256
|
+
<span className="velu-chatbot__brand-title">Ask AI</span>
|
|
257
|
+
</span>
|
|
258
|
+
</Cluster>
|
|
259
|
+
<Cluster space="var(--s-5)" align="center">
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
className={`velu-chatbot__iconbtn${historyOpen ? ' is-active' : ''}`}
|
|
263
|
+
aria-label="History"
|
|
264
|
+
onClick={onHistory}
|
|
265
|
+
>
|
|
266
|
+
{resolveIcon('history', { size: '1em' })}
|
|
267
|
+
</button>
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
className="velu-chatbot__iconbtn"
|
|
271
|
+
aria-label="New chat"
|
|
272
|
+
onClick={onNew}
|
|
273
|
+
>
|
|
274
|
+
{resolveIcon('plus', { size: '1em' })}
|
|
275
|
+
</button>
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
className="velu-chatbot__iconbtn"
|
|
279
|
+
aria-label="Close"
|
|
280
|
+
onClick={onClose}
|
|
281
|
+
>
|
|
282
|
+
{resolveIcon('x', { size: '1em' })}
|
|
283
|
+
</button>
|
|
284
|
+
</Cluster>
|
|
285
|
+
</Cluster>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* ── History overlay ────────────────────────────────────────────────── */
|
|
290
|
+
|
|
291
|
+
/* "Today, 10:24" / "Yesterday" / "Apr 28" for a conversation timestamp. */
|
|
292
|
+
function whenLabel(iso) {
|
|
293
|
+
if (!iso) return '';
|
|
294
|
+
const d = new Date(iso);
|
|
295
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
296
|
+
const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
|
|
297
|
+
const days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
|
|
298
|
+
if (days <= 0) {
|
|
299
|
+
return `Today, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
|
300
|
+
}
|
|
301
|
+
if (days === 1) return 'Yesterday';
|
|
302
|
+
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function HistoryPanel({ items, loading, onPick, onClose }) {
|
|
306
|
+
return (
|
|
307
|
+
<div className="velu-chatbot__history">
|
|
308
|
+
<Cluster
|
|
309
|
+
space="var(--s-2)"
|
|
310
|
+
justify="space-between"
|
|
311
|
+
align="center"
|
|
312
|
+
className="velu-chatbot__history-head"
|
|
313
|
+
>
|
|
314
|
+
<span className="velu-chatbot__eyebrow">Recent chats</span>
|
|
315
|
+
<button
|
|
316
|
+
type="button"
|
|
317
|
+
className="velu-chatbot__iconbtn"
|
|
318
|
+
aria-label="Close history"
|
|
319
|
+
onClick={onClose}
|
|
320
|
+
>
|
|
321
|
+
{resolveIcon('x', { size: '1em' })}
|
|
322
|
+
</button>
|
|
323
|
+
</Cluster>
|
|
324
|
+
<Stack as="div" space="var(--s-5)" className="velu-chatbot__history-list">
|
|
325
|
+
{loading ? (
|
|
326
|
+
<span className="velu-chatbot__history-empty">Loading…</span>
|
|
327
|
+
) : items.length === 0 ? (
|
|
328
|
+
<span className="velu-chatbot__history-empty">No conversations yet.</span>
|
|
329
|
+
) : (
|
|
330
|
+
items.map((h) => (
|
|
331
|
+
<button
|
|
332
|
+
key={h.id}
|
|
333
|
+
type="button"
|
|
334
|
+
className="velu-chatbot__history-item"
|
|
335
|
+
onClick={() => onPick(h)}
|
|
336
|
+
>
|
|
337
|
+
<span className="velu-chatbot__history-item-title">{h.title}</span>
|
|
338
|
+
<span className="velu-chatbot__history-item-when">{h.when}</span>
|
|
339
|
+
</button>
|
|
340
|
+
))
|
|
341
|
+
)}
|
|
342
|
+
</Stack>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* ── Welcome / empty state ──────────────────────────────────────────── */
|
|
348
|
+
function Welcome({ onPick, suggestions }) {
|
|
349
|
+
// Site-specific starter questions (velu.json assistant.suggestions, or
|
|
350
|
+
// publish-time generated); the canned demo list is the dev-preview fallback.
|
|
351
|
+
const items = suggestions?.length
|
|
352
|
+
? suggestions.map((label) => ({ label, icon: 'sparkles' }))
|
|
353
|
+
: SUGGESTIONS;
|
|
354
|
+
return (
|
|
355
|
+
<Stack space="var(--s0)" className="velu-chatbot__welcome">
|
|
356
|
+
<p className="velu-chatbot__welcome-hi">Ask anything about the docs.</p>
|
|
357
|
+
<span className="velu-chatbot__eyebrow">Suggested</span>
|
|
358
|
+
<Stack space="var(--s-4)">
|
|
359
|
+
{items.map((s, i) => (
|
|
360
|
+
<button
|
|
361
|
+
key={i}
|
|
362
|
+
type="button"
|
|
363
|
+
className="velu-chatbot__suggest"
|
|
364
|
+
onClick={() => onPick(s.label)}
|
|
365
|
+
>
|
|
366
|
+
<Cluster space="var(--s-2)" align="center">
|
|
367
|
+
{resolveIcon(s.icon, { size: '1em' })}
|
|
368
|
+
<span>{s.label}</span>
|
|
369
|
+
</Cluster>
|
|
370
|
+
{resolveIcon('arrow-right', { size: '1em' })}
|
|
371
|
+
</button>
|
|
372
|
+
))}
|
|
373
|
+
</Stack>
|
|
374
|
+
</Stack>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* ── Messages ───────────────────────────────────────────────────────── */
|
|
379
|
+
function UserMsg({ text }) {
|
|
380
|
+
return (
|
|
381
|
+
<div className="velu-chatbot__msg velu-chatbot__msg--user">
|
|
382
|
+
<div className="velu-chatbot__bubble">{text}</div>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function AiMsg({
|
|
388
|
+
tokens,
|
|
389
|
+
sources,
|
|
390
|
+
followups,
|
|
391
|
+
streaming,
|
|
392
|
+
onFollowup,
|
|
393
|
+
messageId,
|
|
394
|
+
onFeedback,
|
|
395
|
+
onNavigate,
|
|
396
|
+
}) {
|
|
397
|
+
const thinking = tokens.length === 0;
|
|
398
|
+
const [vote, setVote] = useState(null); // 'up' | 'down' | null
|
|
399
|
+
const [copied, setCopied] = useState(false);
|
|
400
|
+
|
|
401
|
+
const onAction = (ic) => {
|
|
402
|
+
if (ic === 'copy') {
|
|
403
|
+
try {
|
|
404
|
+
navigator.clipboard?.writeText(tokensToPlain(tokens));
|
|
405
|
+
setCopied(true);
|
|
406
|
+
setTimeout(() => setCopied(false), 1400);
|
|
407
|
+
} catch {
|
|
408
|
+
/* clipboard blocked — no-op */
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (ic === 'thumbs-up' || ic === 'thumbs-down') {
|
|
413
|
+
const v = ic === 'thumbs-up' ? 'up' : 'down';
|
|
414
|
+
const next = vote === v ? null : v; // toggle off if re-clicked
|
|
415
|
+
setVote(next);
|
|
416
|
+
onFeedback?.(messageId, next); // null = retract
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
return (
|
|
420
|
+
<div className="velu-chatbot__msg velu-chatbot__msg--ai">
|
|
421
|
+
<Cluster
|
|
422
|
+
space="var(--s-3)"
|
|
423
|
+
align="center"
|
|
424
|
+
className={`velu-chatbot__role${thinking ? ' is-thinking' : ''}`}
|
|
425
|
+
>
|
|
426
|
+
{thinking ? <BlocksWave /> : resolveIcon('sparkles', { size: '1em' })}
|
|
427
|
+
<span>{thinking ? 'Velu is thinking' : 'Velu'}</span>
|
|
428
|
+
</Cluster>
|
|
429
|
+
|
|
430
|
+
{!thinking && (
|
|
431
|
+
<div className="velu-chatbot__answer">
|
|
432
|
+
{renderStream(tokens, sources, onNavigate)}
|
|
433
|
+
{streaming && <span className="velu-chatbot__caret" />}
|
|
434
|
+
|
|
435
|
+
{!streaming && sources?.length > 0 && (
|
|
436
|
+
<Stack space="var(--s-4)" className="velu-chatbot__sources">
|
|
437
|
+
<span className="velu-chatbot__eyebrow">Sources</span>
|
|
438
|
+
{sources.map((s) => (
|
|
439
|
+
<a
|
|
440
|
+
key={s.num}
|
|
441
|
+
className="velu-chatbot__source"
|
|
442
|
+
href={sourceHref(s) || '#'}
|
|
443
|
+
onClick={(e) => onSourceClick(e, s, onNavigate)}
|
|
444
|
+
>
|
|
445
|
+
<span className="velu-chatbot__source-num">{s.num}</span>
|
|
446
|
+
<span className="velu-chatbot__source-meta">
|
|
447
|
+
<span className="velu-chatbot__source-title">{citeTitle(s)}</span>
|
|
448
|
+
<span className="velu-chatbot__source-path">{s.path}</span>
|
|
449
|
+
</span>
|
|
450
|
+
</a>
|
|
451
|
+
))}
|
|
452
|
+
</Stack>
|
|
453
|
+
)}
|
|
454
|
+
|
|
455
|
+
{!streaming && followups?.length > 0 && (
|
|
456
|
+
<Stack space="var(--s-4)" className="velu-chatbot__followups">
|
|
457
|
+
<span className="velu-chatbot__eyebrow">Follow up</span>
|
|
458
|
+
{followups.map((f, i) => (
|
|
459
|
+
<button
|
|
460
|
+
key={i}
|
|
461
|
+
type="button"
|
|
462
|
+
className="velu-chatbot__followup"
|
|
463
|
+
onClick={() => onFollowup(f)}
|
|
464
|
+
>
|
|
465
|
+
<span>{f}</span>
|
|
466
|
+
{resolveIcon('arrow-right', { size: '1em' })}
|
|
467
|
+
</button>
|
|
468
|
+
))}
|
|
469
|
+
</Stack>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
|
|
474
|
+
{!streaming && !thinking && (
|
|
475
|
+
<Cluster space="0" className="velu-chatbot__msg-actions">
|
|
476
|
+
{['copy', 'thumbs-up', 'thumbs-down'].map((ic) => {
|
|
477
|
+
const active =
|
|
478
|
+
(ic === 'thumbs-up' && vote === 'up') ||
|
|
479
|
+
(ic === 'thumbs-down' && vote === 'down');
|
|
480
|
+
const label = ic === 'copy' && copied ? 'check' : ic;
|
|
481
|
+
return (
|
|
482
|
+
<button
|
|
483
|
+
key={ic}
|
|
484
|
+
type="button"
|
|
485
|
+
className={`velu-chatbot__msg-action${active ? ' is-active' : ''}`}
|
|
486
|
+
aria-label={ic}
|
|
487
|
+
aria-pressed={ic === 'copy' ? undefined : active}
|
|
488
|
+
onClick={() => onAction(ic)}
|
|
489
|
+
>
|
|
490
|
+
{resolveIcon(label, { size: '1em' })}
|
|
491
|
+
</button>
|
|
492
|
+
);
|
|
493
|
+
})}
|
|
494
|
+
</Cluster>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* ── Composer ───────────────────────────────────────────────────────── */
|
|
501
|
+
function Composer({ value, onChange, onSubmit, disabled }) {
|
|
502
|
+
const taRef = useRef(null);
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
const ta = taRef.current;
|
|
505
|
+
if (!ta) return;
|
|
506
|
+
ta.style.height = 'auto';
|
|
507
|
+
ta.style.height = `${ta.scrollHeight}px`;
|
|
508
|
+
}, [value]);
|
|
509
|
+
|
|
510
|
+
const active = value.trim().length > 0 && !disabled;
|
|
511
|
+
const onKeyDown = (e) => {
|
|
512
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
513
|
+
e.preventDefault();
|
|
514
|
+
if (active) onSubmit();
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<div className="velu-chatbot__composer">
|
|
520
|
+
<div className="velu-chatbot__input-wrap">
|
|
521
|
+
<textarea
|
|
522
|
+
ref={taRef}
|
|
523
|
+
className="velu-chatbot__input"
|
|
524
|
+
placeholder="Ask anything about the docs…"
|
|
525
|
+
rows={1}
|
|
526
|
+
value={value}
|
|
527
|
+
onChange={(e) => onChange(e.target.value)}
|
|
528
|
+
onKeyDown={onKeyDown}
|
|
529
|
+
/>
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
className={`velu-chatbot__send${active ? ' is-active' : ''}`}
|
|
533
|
+
onClick={onSubmit}
|
|
534
|
+
disabled={!active}
|
|
535
|
+
aria-label="Send"
|
|
536
|
+
>
|
|
537
|
+
{resolveIcon('arrow-up', { size: '1em' })}
|
|
538
|
+
</button>
|
|
539
|
+
</div>
|
|
540
|
+
<div className="velu-chatbot__foot">
|
|
541
|
+
<kbd>Enter</kbd> to send · <kbd>⇧ Enter</kbd> for newline
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/* ── Panel ──────────────────────────────────────────────────────────── */
|
|
548
|
+
export default function Chatbot({
|
|
549
|
+
open = false,
|
|
550
|
+
seedQuestion,
|
|
551
|
+
onClose,
|
|
552
|
+
ask,
|
|
553
|
+
onFeedback,
|
|
554
|
+
onNavigate,
|
|
555
|
+
listHistory,
|
|
556
|
+
loadConversation,
|
|
557
|
+
suggestions,
|
|
558
|
+
className = '',
|
|
559
|
+
...rest
|
|
560
|
+
}) {
|
|
561
|
+
const [messages, setMessages] = useState([]);
|
|
562
|
+
const [input, setInput] = useState('');
|
|
563
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
564
|
+
const [history, setHistory] = useState([]);
|
|
565
|
+
const [historyLoading, setHistoryLoading] = useState(false);
|
|
566
|
+
const bodyRef = useRef(null);
|
|
567
|
+
const streamingRef = useRef(false);
|
|
568
|
+
const lastSeedRef = useRef(undefined);
|
|
569
|
+
// Real-backend session: conversation id threads turns; the controller lets
|
|
570
|
+
// a new-chat / close / unmount abort an in-flight stream.
|
|
571
|
+
const convoRef = useRef(undefined);
|
|
572
|
+
const abortRef = useRef(null);
|
|
573
|
+
|
|
574
|
+
useEffect(() => {
|
|
575
|
+
const el = bodyRef.current;
|
|
576
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
577
|
+
}, [messages]);
|
|
578
|
+
|
|
579
|
+
// Merge a patch into the last (assistant) message.
|
|
580
|
+
const patchLast = useCallback((patch) => {
|
|
581
|
+
setMessages((prev) => {
|
|
582
|
+
if (!prev.length) return prev;
|
|
583
|
+
const out = prev.slice();
|
|
584
|
+
out[out.length - 1] = { ...out[out.length - 1], ...patch };
|
|
585
|
+
return out;
|
|
586
|
+
});
|
|
587
|
+
}, []);
|
|
588
|
+
|
|
589
|
+
// Stop whatever is in flight — a canned setTimeout chain ({ timer }) or a
|
|
590
|
+
// real fetch stream (an AbortController). Safe to call any time.
|
|
591
|
+
const cancelInFlight = useCallback(() => {
|
|
592
|
+
const a = abortRef.current;
|
|
593
|
+
if (a) {
|
|
594
|
+
if (a.timer) clearTimeout(a.timer);
|
|
595
|
+
if (typeof a.abort === 'function') a.abort();
|
|
596
|
+
}
|
|
597
|
+
abortRef.current = null;
|
|
598
|
+
streamingRef.current = false;
|
|
599
|
+
}, []);
|
|
600
|
+
|
|
601
|
+
// Real history: fetch the visitor's conversations when the panel opens.
|
|
602
|
+
// Without a backend (dev preview) the canned demo entries show instead.
|
|
603
|
+
useEffect(() => {
|
|
604
|
+
if (!historyOpen) return;
|
|
605
|
+
if (!listHistory) {
|
|
606
|
+
setHistory(HISTORY.map((h) => ({ ...h, demo: true })));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
let stale = false;
|
|
610
|
+
setHistoryLoading(true);
|
|
611
|
+
listHistory()
|
|
612
|
+
.then((items) => {
|
|
613
|
+
if (stale) return;
|
|
614
|
+
setHistory(items.map((c) => ({
|
|
615
|
+
id: c.conversationId,
|
|
616
|
+
title: c.title,
|
|
617
|
+
when: whenLabel(c.updatedAt),
|
|
618
|
+
conversation: c,
|
|
619
|
+
})));
|
|
620
|
+
})
|
|
621
|
+
.catch(() => { if (!stale) setHistory([]); })
|
|
622
|
+
.finally(() => { if (!stale) setHistoryLoading(false); });
|
|
623
|
+
return () => { stale = true; };
|
|
624
|
+
}, [historyOpen, listHistory]);
|
|
625
|
+
|
|
626
|
+
// Resume a past conversation: load its turns into the panel and thread the
|
|
627
|
+
// conversation id so follow-up asks continue it.
|
|
628
|
+
const openConversation = useCallback(
|
|
629
|
+
async (item) => {
|
|
630
|
+
setHistoryOpen(false);
|
|
631
|
+
if (!item.conversation || !loadConversation) return; // demo entry
|
|
632
|
+
cancelInFlight();
|
|
633
|
+
try {
|
|
634
|
+
const { conversationId, messages: msgs } = await loadConversation(item.conversation);
|
|
635
|
+
convoRef.current = conversationId;
|
|
636
|
+
setMessages(msgs.map((m) =>
|
|
637
|
+
m.role === 'user'
|
|
638
|
+
? { role: 'user', text: m.content }
|
|
639
|
+
: {
|
|
640
|
+
role: 'ai',
|
|
641
|
+
tokens: textToTokens(m.content),
|
|
642
|
+
sources: m.citations || [],
|
|
643
|
+
followups: [],
|
|
644
|
+
streaming: false,
|
|
645
|
+
messageId: m.messageId,
|
|
646
|
+
},
|
|
647
|
+
));
|
|
648
|
+
} catch {
|
|
649
|
+
/* keep the current chat if the load fails */
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
[loadConversation, cancelInFlight],
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
const send = useCallback(
|
|
656
|
+
async (text) => {
|
|
657
|
+
const prompt = String(text ?? '').trim();
|
|
658
|
+
if (!prompt || streamingRef.current) return;
|
|
659
|
+
setInput('');
|
|
660
|
+
|
|
661
|
+
// ── No backend wired → canned, fake-streamed demo answer ──────────
|
|
662
|
+
if (!ask) {
|
|
663
|
+
const answer = buildAnswer();
|
|
664
|
+
const fullTokens = tokenizeAnswer(answer);
|
|
665
|
+
setMessages((prev) => [
|
|
666
|
+
...prev,
|
|
667
|
+
{ role: 'user', text: prompt },
|
|
668
|
+
{
|
|
669
|
+
role: 'ai',
|
|
670
|
+
tokens: [],
|
|
671
|
+
sources: answer.sources,
|
|
672
|
+
followups: answer.followups,
|
|
673
|
+
streaming: true,
|
|
674
|
+
},
|
|
675
|
+
]);
|
|
676
|
+
streamingRef.current = true;
|
|
677
|
+
let i = 0;
|
|
678
|
+
const step = () => {
|
|
679
|
+
i = Math.min(fullTokens.length, i + 1);
|
|
680
|
+
patchLast({
|
|
681
|
+
tokens: fullTokens.slice(0, i),
|
|
682
|
+
streaming: i < fullTokens.length,
|
|
683
|
+
});
|
|
684
|
+
if (i < fullTokens.length) {
|
|
685
|
+
abortRef.current = { timer: setTimeout(step, 28) };
|
|
686
|
+
} else {
|
|
687
|
+
streamingRef.current = false;
|
|
688
|
+
abortRef.current = null;
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
abortRef.current = { timer: setTimeout(step, 1400) }; // "thinking" pause
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ── Real backend → stream deltas + citations from `ask` ───────────
|
|
696
|
+
setMessages((prev) => [
|
|
697
|
+
...prev,
|
|
698
|
+
{ role: 'user', text: prompt },
|
|
699
|
+
{ role: 'ai', tokens: [], sources: [], followups: [], streaming: true },
|
|
700
|
+
]);
|
|
701
|
+
streamingRef.current = true;
|
|
702
|
+
const controller =
|
|
703
|
+
typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
704
|
+
abortRef.current = controller;
|
|
705
|
+
|
|
706
|
+
let acc = '';
|
|
707
|
+
let sources = [];
|
|
708
|
+
let messageId;
|
|
709
|
+
try {
|
|
710
|
+
for await (const ev of ask(prompt, {
|
|
711
|
+
conversationId: convoRef.current,
|
|
712
|
+
signal: controller?.signal,
|
|
713
|
+
})) {
|
|
714
|
+
if (ev.conversationId) convoRef.current = ev.conversationId;
|
|
715
|
+
if (ev.messageId) messageId = ev.messageId;
|
|
716
|
+
if (ev.delta) acc += ev.delta;
|
|
717
|
+
if (ev.citations) {
|
|
718
|
+
sources = ev.citations.map((c, idx) => ({
|
|
719
|
+
num: c.num ?? idx + 1,
|
|
720
|
+
title: c.title,
|
|
721
|
+
path: c.path ?? c.route_path,
|
|
722
|
+
url: c.url,
|
|
723
|
+
}));
|
|
724
|
+
}
|
|
725
|
+
patchLast({ tokens: textToTokens(acc), sources, streaming: true, messageId });
|
|
726
|
+
}
|
|
727
|
+
patchLast({ tokens: textToTokens(acc), sources, streaming: false, messageId });
|
|
728
|
+
} catch (err) {
|
|
729
|
+
if (controller?.signal?.aborted) {
|
|
730
|
+
patchLast({ streaming: false });
|
|
731
|
+
} else {
|
|
732
|
+
patchLast({
|
|
733
|
+
tokens: textToTokens(
|
|
734
|
+
acc || 'Sorry — I couldn’t reach the assistant. Please try again.'
|
|
735
|
+
),
|
|
736
|
+
streaming: false,
|
|
737
|
+
error: true,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
} finally {
|
|
741
|
+
streamingRef.current = false;
|
|
742
|
+
abortRef.current = null;
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
[ask, patchLast]
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
// Abort any in-flight stream when the panel unmounts.
|
|
749
|
+
useEffect(() => () => cancelInFlight(), [cancelInFlight]);
|
|
750
|
+
|
|
751
|
+
// A new seedQuestion (from the page's AskBar) sends the first message.
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
if (open && seedQuestion && seedQuestion !== lastSeedRef.current) {
|
|
754
|
+
lastSeedRef.current = seedQuestion;
|
|
755
|
+
send(seedQuestion);
|
|
756
|
+
}
|
|
757
|
+
}, [open, seedQuestion, send]);
|
|
758
|
+
|
|
759
|
+
const newChat = () => {
|
|
760
|
+
cancelInFlight();
|
|
761
|
+
convoRef.current = undefined; // fresh conversation thread
|
|
762
|
+
setMessages([]);
|
|
763
|
+
setInput('');
|
|
764
|
+
setHistoryOpen(false);
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// Drag-to-dismiss (mobile bottom-sheet only). Pointer events are
|
|
768
|
+
// attached to the drag-handle element below; the handle's pointer
|
|
769
|
+
// capture keeps the events flowing even if the finger slides off
|
|
770
|
+
// the handle into the body. While dragging we apply an inline
|
|
771
|
+
// `transform: translateY(...)` to the panel, overriding the CSS-
|
|
772
|
+
// controlled transform. On release, if the drag distance exceeded
|
|
773
|
+
// the threshold we call onClose() and the panel slides the rest of
|
|
774
|
+
// the way down via the CSS transition; otherwise we drop the
|
|
775
|
+
// inline transform and the panel snaps back via the same
|
|
776
|
+
// transition.
|
|
777
|
+
const dragRef = useRef({ active: false, startY: 0, currentY: 0 });
|
|
778
|
+
const [dragOffset, setDragOffset] = useState(0);
|
|
779
|
+
// Drag-to-dismiss is active wherever the chatbot is rendered as a
|
|
780
|
+
// bottom sheet — same threshold as the @container query in
|
|
781
|
+
// chatbot.css (< 1024px covers both narrow / tablet and mobile).
|
|
782
|
+
const isBottomSheet = () =>
|
|
783
|
+
typeof window !== 'undefined' &&
|
|
784
|
+
window.matchMedia('(max-width: 1024px)').matches;
|
|
785
|
+
const onDragStart = (e) => {
|
|
786
|
+
if (!isBottomSheet()) return;
|
|
787
|
+
dragRef.current = { active: true, startY: e.clientY, currentY: 0 };
|
|
788
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
789
|
+
};
|
|
790
|
+
const onDragMove = (e) => {
|
|
791
|
+
if (!dragRef.current.active) return;
|
|
792
|
+
const delta = Math.max(0, e.clientY - dragRef.current.startY);
|
|
793
|
+
dragRef.current.currentY = delta;
|
|
794
|
+
setDragOffset(delta);
|
|
795
|
+
};
|
|
796
|
+
const onDragEnd = () => {
|
|
797
|
+
if (!dragRef.current.active) return;
|
|
798
|
+
dragRef.current.active = false;
|
|
799
|
+
const shouldClose = dragRef.current.currentY > 80;
|
|
800
|
+
setDragOffset(0);
|
|
801
|
+
if (shouldClose) onClose?.();
|
|
802
|
+
};
|
|
803
|
+
// Reset any residual drag offset whenever the panel closes from
|
|
804
|
+
// outside (scrim click, X button, etc.) so a re-open starts clean.
|
|
805
|
+
useEffect(() => {
|
|
806
|
+
if (!open) setDragOffset(0);
|
|
807
|
+
}, [open]);
|
|
808
|
+
|
|
809
|
+
const cls = `velu-chatbot${open ? ' velu-chatbot--open' : ''} ${className}`.trim();
|
|
810
|
+
const dragStyle =
|
|
811
|
+
dragOffset > 0
|
|
812
|
+
? { transform: `translateY(${dragOffset}px)`, transition: 'none' }
|
|
813
|
+
: undefined;
|
|
814
|
+
|
|
815
|
+
return (
|
|
816
|
+
<aside
|
|
817
|
+
className={cls}
|
|
818
|
+
aria-hidden={!open}
|
|
819
|
+
aria-label="Ask AI"
|
|
820
|
+
style={dragStyle}
|
|
821
|
+
{...rest}
|
|
822
|
+
>
|
|
823
|
+
{/* Drag handle — mobile only (CSS in chatbot.css). The pill
|
|
824
|
+
visual + the touch-capturing strip across the top of the
|
|
825
|
+
sheet. Pointer events here drive the drag-to-dismiss
|
|
826
|
+
gesture above. */}
|
|
827
|
+
<div
|
|
828
|
+
className="velu-chatbot__drag-handle"
|
|
829
|
+
onPointerDown={onDragStart}
|
|
830
|
+
onPointerMove={onDragMove}
|
|
831
|
+
onPointerUp={onDragEnd}
|
|
832
|
+
onPointerCancel={onDragEnd}
|
|
833
|
+
aria-hidden="true"
|
|
834
|
+
/>
|
|
835
|
+
<ChatHeader
|
|
836
|
+
onClose={onClose}
|
|
837
|
+
onNew={newChat}
|
|
838
|
+
onHistory={() => setHistoryOpen((o) => !o)}
|
|
839
|
+
historyOpen={historyOpen}
|
|
840
|
+
/>
|
|
841
|
+
|
|
842
|
+
{historyOpen && (
|
|
843
|
+
<HistoryPanel
|
|
844
|
+
items={history}
|
|
845
|
+
loading={historyLoading}
|
|
846
|
+
onClose={() => setHistoryOpen(false)}
|
|
847
|
+
onPick={openConversation}
|
|
848
|
+
/>
|
|
849
|
+
)}
|
|
850
|
+
|
|
851
|
+
<div className="velu-chatbot__body" ref={bodyRef}>
|
|
852
|
+
{messages.length === 0 ? (
|
|
853
|
+
<Welcome onPick={(t) => send(t)} suggestions={suggestions} />
|
|
854
|
+
) : (
|
|
855
|
+
<Stack space="var(--s1)">
|
|
856
|
+
{messages.map((m, i) =>
|
|
857
|
+
m.role === 'user' ? (
|
|
858
|
+
<UserMsg key={i} text={m.text} />
|
|
859
|
+
) : (
|
|
860
|
+
<AiMsg
|
|
861
|
+
key={i}
|
|
862
|
+
tokens={m.tokens}
|
|
863
|
+
sources={m.sources}
|
|
864
|
+
followups={m.followups}
|
|
865
|
+
streaming={m.streaming}
|
|
866
|
+
messageId={m.messageId}
|
|
867
|
+
onFeedback={onFeedback}
|
|
868
|
+
onNavigate={onNavigate}
|
|
869
|
+
onFollowup={(f) => send(f)}
|
|
870
|
+
/>
|
|
871
|
+
),
|
|
872
|
+
)}
|
|
873
|
+
</Stack>
|
|
874
|
+
)}
|
|
875
|
+
</div>
|
|
876
|
+
|
|
877
|
+
<Composer
|
|
878
|
+
value={input}
|
|
879
|
+
onChange={setInput}
|
|
880
|
+
onSubmit={() => send(input)}
|
|
881
|
+
disabled={streamingRef.current}
|
|
882
|
+
/>
|
|
883
|
+
</aside>
|
|
884
|
+
);
|
|
885
|
+
}
|