@veluai/velu 0.2.24 → 0.2.27

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.
Files changed (114) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +44 -44
  3. package/package.json +64 -64
  4. package/runtime/velu-ui/base.css +320 -320
  5. package/runtime/velu-ui/components/Accordion.jsx +64 -64
  6. package/runtime/velu-ui/components/ApiClient.jsx +207 -207
  7. package/runtime/velu-ui/components/ApiField.jsx +87 -87
  8. package/runtime/velu-ui/components/ApiPath.jsx +63 -63
  9. package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
  10. package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
  11. package/runtime/velu-ui/components/ApiSidebar.jsx +125 -125
  12. package/runtime/velu-ui/components/AskBar.jsx +71 -71
  13. package/runtime/velu-ui/components/Callout.jsx +114 -114
  14. package/runtime/velu-ui/components/Card.jsx +131 -131
  15. package/runtime/velu-ui/components/ChangelogFilters.jsx +75 -75
  16. package/runtime/velu-ui/components/Chatbot.jsx +972 -972
  17. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  18. package/runtime/velu-ui/components/Columns.jsx +56 -56
  19. package/runtime/velu-ui/components/ContextMenu.jsx +327 -327
  20. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  21. package/runtime/velu-ui/components/Field.jsx +81 -81
  22. package/runtime/velu-ui/components/Image.jsx +163 -163
  23. package/runtime/velu-ui/components/Logo.jsx +31 -31
  24. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  25. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  26. package/runtime/velu-ui/components/NotFound.jsx +63 -63
  27. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  28. package/runtime/velu-ui/components/PageFooter.jsx +145 -145
  29. package/runtime/velu-ui/components/PageHeader.jsx +422 -422
  30. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  31. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  32. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  33. package/runtime/velu-ui/components/Search.jsx +460 -460
  34. package/runtime/velu-ui/components/Sidebar.jsx +246 -246
  35. package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
  36. package/runtime/velu-ui/components/Steps.jsx +65 -65
  37. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  38. package/runtime/velu-ui/components/Toc.jsx +537 -537
  39. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  40. package/runtime/velu-ui/components/Tree.jsx +87 -87
  41. package/runtime/velu-ui/components/TryItBar.jsx +102 -102
  42. package/runtime/velu-ui/components/Update.jsx +92 -92
  43. package/runtime/velu-ui/components/accordion.css +92 -92
  44. package/runtime/velu-ui/components/api-page.css +208 -208
  45. package/runtime/velu-ui/components/api.css +635 -635
  46. package/runtime/velu-ui/components/ask-bar.css +103 -94
  47. package/runtime/velu-ui/components/card.css +105 -105
  48. package/runtime/velu-ui/components/changelog-filters.css +43 -43
  49. package/runtime/velu-ui/components/chatbot.css +687 -687
  50. package/runtime/velu-ui/components/code-block.css +263 -263
  51. package/runtime/velu-ui/components/context-menu.css +199 -199
  52. package/runtime/velu-ui/components/docs-layout.css +822 -822
  53. package/runtime/velu-ui/components/field.css +82 -82
  54. package/runtime/velu-ui/components/image.css +237 -237
  55. package/runtime/velu-ui/components/nav-select.css +157 -157
  56. package/runtime/velu-ui/components/not-found.css +94 -94
  57. package/runtime/velu-ui/components/page-feedback.css +261 -261
  58. package/runtime/velu-ui/components/page-footer.css +130 -130
  59. package/runtime/velu-ui/components/page-header.css +558 -558
  60. package/runtime/velu-ui/components/page-nav.css +50 -50
  61. package/runtime/velu-ui/components/powered-by.css +92 -92
  62. package/runtime/velu-ui/components/prompt.css +99 -99
  63. package/runtime/velu-ui/components/search.css +307 -307
  64. package/runtime/velu-ui/components/sidebar.css +205 -205
  65. package/runtime/velu-ui/components/steps.css +77 -77
  66. package/runtime/velu-ui/components/theme-toggle.css +102 -102
  67. package/runtime/velu-ui/components/toc-bar.css +234 -234
  68. package/runtime/velu-ui/components/tree.css +49 -49
  69. package/runtime/velu-ui/components/update.css +111 -111
  70. package/runtime/velu-ui/index.js +58 -58
  71. package/runtime/velu-ui/lib/api-send.js +118 -118
  72. package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
  73. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  74. package/runtime/velu-ui/lib/copyText.js +64 -64
  75. package/runtime/velu-ui/lib/docs-assistant.js +273 -273
  76. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  77. package/runtime/velu-ui/lib/page-feedback.js +72 -72
  78. package/runtime/velu-ui/lib/pagefind.js +116 -116
  79. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  80. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  81. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  82. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  83. package/runtime/velu-ui/mdx-components.jsx +107 -107
  84. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  85. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  86. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  87. package/runtime/velu-ui/primitives/stack.css +3 -3
  88. package/runtime/velu-ui/primitives/switcher.css +25 -25
  89. package/runtime/velu-ui/styles.css +48 -48
  90. package/runtime/velu-ui/tokens.css +4 -4
  91. package/schema/velu.schema.json +419 -419
  92. package/src/lib/extract-mdx-error.js +170 -170
  93. package/src/lib/issues.js +159 -159
  94. package/src/lib/known-components.js +35 -35
  95. package/src/navigation.js +459 -459
  96. package/src/runtime/App.jsx +1754 -1754
  97. package/src/runtime/ErrorBoundary.jsx +54 -54
  98. package/src/runtime/client-entry.jsx +27 -27
  99. package/src/runtime/server-entry.jsx +24 -24
  100. package/src/template.html +48 -48
  101. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  102. package/templates/starter/ai-tools/cursor.mdx +17 -17
  103. package/templates/starter/api-reference/introduction.mdx +43 -43
  104. package/templates/starter/development.mdx +19 -19
  105. package/templates/starter/essentials/code.mdx +29 -29
  106. package/templates/starter/essentials/images.mdx +29 -29
  107. package/templates/starter/essentials/markdown.mdx +25 -25
  108. package/templates/starter/essentials/navigation.mdx +39 -39
  109. package/templates/starter/essentials/settings.mdx +30 -30
  110. package/templates/starter/favicon.svg +6 -6
  111. package/templates/starter/index.mdx +31 -31
  112. package/templates/starter/openapi.json +160 -160
  113. package/templates/starter/quickstart.mdx +31 -31
  114. package/templates/starter/velu.json +40 -40
@@ -1,972 +1,972 @@
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
- /* Flatten the token stream back to a markdown string (code → `…`, citation →
199
- [n]) so the assistant's markdown answer can be rendered with formatting. */
200
- function tokensToMarkdown(tokens) {
201
- return tokens
202
- .map((tk) => (tk.kind === 'cite' ? `[${tk.n}]` : tk.kind === 'code' ? `\`${tk.v}\`` : tk.v))
203
- .join('');
204
- }
205
-
206
- const CITE_RE = /\[(\d+)\]/;
207
- const INLINE_RULES = [
208
- { re: /\*\*([^*]+)\*\*/, type: 'bold' },
209
- { re: /__([^_]+)__/, type: 'bold' },
210
- { re: /\*([^*\n]+)\*/, type: 'italic' },
211
- { re: /(?<![A-Za-z0-9_])_([^_\n]+)_(?![A-Za-z0-9_])/, type: 'italic' },
212
- { re: /`([^`]+)`/, type: 'code' },
213
- { re: /\[([^\]]+)\]\(([^)]+)\)/, type: 'link' },
214
- { re: CITE_RE, type: 'cite' },
215
- ];
216
-
217
- /* Inline markdown → React nodes: **bold**, *italic*, `code`, [text](url) links,
218
- and [n] citation chips. Builds React elements (never innerHTML) so it's XSS-safe. */
219
- function renderInline(text, sources, onNavigate, keyBase) {
220
- const out = [];
221
- let rest = text;
222
- let k = 0;
223
- while (rest) {
224
- let best = null;
225
- for (const rule of INLINE_RULES) {
226
- const m = rule.re.exec(rest);
227
- if (m && (best === null || m.index < best.m.index)) best = { rule, m };
228
- }
229
- if (!best) {
230
- out.push(<React.Fragment key={`${keyBase}-${k++}`}>{rest}</React.Fragment>);
231
- break;
232
- }
233
- const { rule, m } = best;
234
- if (m.index > 0) out.push(<React.Fragment key={`${keyBase}-${k++}`}>{rest.slice(0, m.index)}</React.Fragment>);
235
- const key = `${keyBase}-${k++}`;
236
- if (rule.type === 'bold') out.push(<strong key={key}>{m[1]}</strong>);
237
- else if (rule.type === 'italic') out.push(<em key={key}>{m[1]}</em>);
238
- else if (rule.type === 'code') out.push(<code key={key}>{m[1]}</code>);
239
- else if (rule.type === 'link') out.push(
240
- <a key={key} href={m[2]} target="_blank" rel="noopener noreferrer">{m[1]}</a>,
241
- );
242
- else if (rule.type === 'cite') {
243
- const n = Number(m[1]);
244
- const src = sources?.find((s) => s.num === n);
245
- out.push(
246
- <a
247
- key={key}
248
- className="velu-chatbot__cite"
249
- href={sourceHref(src) || '#'}
250
- onClick={(e) => onSourceClick(e, src, onNavigate)}
251
- title={citeTitle(src)}
252
- >
253
- {n}
254
- </a>,
255
- );
256
- }
257
- rest = rest.slice(m.index + m[0].length);
258
- }
259
- return out;
260
- }
261
-
262
- /* Block-level markdown → React: headings, bullet/numbered lists, fenced code
263
- blocks, blockquotes, and paragraphs (each rendered through renderInline). */
264
- function renderStream(tokens, sources, onNavigate) {
265
- const text = tokensToMarkdown(tokens);
266
- const lines = text.replace(/\r/g, '').split('\n');
267
- const blocks = [];
268
- let i = 0;
269
- let b = 0;
270
- const listRe = /^\s*([-*+]|\d+\.)\s+/;
271
- while (i < lines.length) {
272
- const line = lines[i];
273
- if (!line.trim()) { i++; continue; }
274
-
275
- if (line.trim().startsWith('```')) {
276
- const buf = [];
277
- i++;
278
- while (i < lines.length && !lines[i].trim().startsWith('```')) { buf.push(lines[i]); i++; }
279
- i++;
280
- blocks.push(<pre key={`b${b++}`} className="velu-chatbot__pre"><code>{buf.join('\n')}</code></pre>);
281
- continue;
282
- }
283
-
284
- const h = /^(#{1,6})\s+(.*)$/.exec(line);
285
- if (h) {
286
- const lvl = Math.min(h[1].length + 2, 6);
287
- const Tag = `h${lvl}`;
288
- blocks.push(<Tag key={`b${b++}`}>{renderInline(h[2], sources, onNavigate, `b${b}`)}</Tag>);
289
- i++;
290
- continue;
291
- }
292
-
293
- if (listRe.test(line)) {
294
- const ordered = /^\s*\d+\.\s+/.test(line);
295
- const items = [];
296
- while (i < lines.length && listRe.test(lines[i])) {
297
- const content = lines[i].replace(listRe, '');
298
- items.push(<li key={`li${b}-${i}`}>{renderInline(content, sources, onNavigate, `li${b}-${i}`)}</li>);
299
- i++;
300
- }
301
- const ListTag = ordered ? 'ol' : 'ul';
302
- blocks.push(<ListTag key={`b${b++}`}>{items}</ListTag>);
303
- continue;
304
- }
305
-
306
- if (line.trim().startsWith('>')) {
307
- const buf = [];
308
- while (i < lines.length && lines[i].trim().startsWith('>')) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
309
- blocks.push(<blockquote key={`b${b++}`}>{renderInline(buf.join(' '), sources, onNavigate, `bq${b}`)}</blockquote>);
310
- continue;
311
- }
312
-
313
- const buf = [];
314
- while (
315
- i < lines.length && lines[i].trim()
316
- && !listRe.test(lines[i]) && !/^#{1,6}\s/.test(lines[i])
317
- && !lines[i].trim().startsWith('```') && !lines[i].trim().startsWith('>')
318
- ) { buf.push(lines[i]); i++; }
319
- const pnodes = [];
320
- buf.forEach((ln, li) => {
321
- if (li > 0) pnodes.push(<br key={`p${b}-br${li}`} />);
322
- pnodes.push(...renderInline(ln, sources, onNavigate, `p${b}-${li}`));
323
- });
324
- blocks.push(<p key={`b${b++}`}>{pnodes}</p>);
325
- }
326
- return blocks;
327
- }
328
-
329
- /* ── Header ─────────────────────────────────────────────────────────── */
330
- function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
331
- return (
332
- <Cluster
333
- space="var(--s-2)"
334
- justify="space-between"
335
- align="center"
336
- className="velu-chatbot__header"
337
- >
338
- <Cluster space="var(--s-2)" align="center">
339
- <VeluMark />
340
- <span className="velu-chatbot__brand">
341
- <span className="velu-chatbot__brand-name">Velu</span>
342
- <span className="velu-chatbot__brand-sep">/</span>
343
- <span className="velu-chatbot__brand-title">Ask AI</span>
344
- </span>
345
- </Cluster>
346
- <Cluster space="var(--s-5)" align="center">
347
- <button
348
- type="button"
349
- className={`velu-chatbot__iconbtn${historyOpen ? ' is-active' : ''}`}
350
- aria-label="History"
351
- onClick={onHistory}
352
- >
353
- {resolveIcon('history', { size: '1em' })}
354
- </button>
355
- <button
356
- type="button"
357
- className="velu-chatbot__iconbtn"
358
- aria-label="New chat"
359
- onClick={onNew}
360
- >
361
- {resolveIcon('plus', { size: '1em' })}
362
- </button>
363
- <button
364
- type="button"
365
- className="velu-chatbot__iconbtn"
366
- aria-label="Close"
367
- onClick={onClose}
368
- >
369
- {resolveIcon('x', { size: '1em' })}
370
- </button>
371
- </Cluster>
372
- </Cluster>
373
- );
374
- }
375
-
376
- /* ── History overlay ────────────────────────────────────────────────── */
377
-
378
- /* "Today, 10:24" / "Yesterday" / "Apr 28" for a conversation timestamp. */
379
- function whenLabel(iso) {
380
- if (!iso) return '';
381
- const d = new Date(iso);
382
- if (Number.isNaN(d.getTime())) return '';
383
- const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
384
- const days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
385
- if (days <= 0) {
386
- return `Today, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
387
- }
388
- if (days === 1) return 'Yesterday';
389
- return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
390
- }
391
-
392
- function HistoryPanel({ items, loading, onPick, onClose }) {
393
- return (
394
- <div className="velu-chatbot__history">
395
- <Cluster
396
- space="var(--s-2)"
397
- justify="space-between"
398
- align="center"
399
- className="velu-chatbot__history-head"
400
- >
401
- <span className="velu-chatbot__eyebrow">Recent chats</span>
402
- <button
403
- type="button"
404
- className="velu-chatbot__iconbtn"
405
- aria-label="Close history"
406
- onClick={onClose}
407
- >
408
- {resolveIcon('x', { size: '1em' })}
409
- </button>
410
- </Cluster>
411
- <Stack as="div" space="var(--s-5)" className="velu-chatbot__history-list">
412
- {loading ? (
413
- <span className="velu-chatbot__history-empty">Loading…</span>
414
- ) : items.length === 0 ? (
415
- <span className="velu-chatbot__history-empty">No conversations yet.</span>
416
- ) : (
417
- items.map((h) => (
418
- <button
419
- key={h.id}
420
- type="button"
421
- className="velu-chatbot__history-item"
422
- onClick={() => onPick(h)}
423
- >
424
- <span className="velu-chatbot__history-item-title">{h.title}</span>
425
- <span className="velu-chatbot__history-item-when">{h.when}</span>
426
- </button>
427
- ))
428
- )}
429
- </Stack>
430
- </div>
431
- );
432
- }
433
-
434
- /* ── Welcome / empty state ──────────────────────────────────────────── */
435
- function Welcome({ onPick, suggestions }) {
436
- // Site-specific starter questions (velu.json assistant.suggestions, or
437
- // publish-time generated); the canned demo list is the dev-preview fallback.
438
- const items = suggestions?.length
439
- ? suggestions.map((label) => ({ label, icon: 'sparkles' }))
440
- : SUGGESTIONS;
441
- return (
442
- <Stack space="var(--s0)" className="velu-chatbot__welcome">
443
- <p className="velu-chatbot__welcome-hi">Ask anything about the docs.</p>
444
- <span className="velu-chatbot__eyebrow">Suggested</span>
445
- <Stack space="var(--s-4)">
446
- {items.map((s, i) => (
447
- <button
448
- key={i}
449
- type="button"
450
- className="velu-chatbot__suggest"
451
- onClick={() => onPick(s.label)}
452
- >
453
- <Cluster space="var(--s-2)" align="center">
454
- {resolveIcon(s.icon, { size: '1em' })}
455
- <span>{s.label}</span>
456
- </Cluster>
457
- {resolveIcon('arrow-right', { size: '1em' })}
458
- </button>
459
- ))}
460
- </Stack>
461
- </Stack>
462
- );
463
- }
464
-
465
- /* ── Messages ───────────────────────────────────────────────────────── */
466
- function UserMsg({ text }) {
467
- return (
468
- <div className="velu-chatbot__msg velu-chatbot__msg--user">
469
- <div className="velu-chatbot__bubble">{text}</div>
470
- </div>
471
- );
472
- }
473
-
474
- function AiMsg({
475
- tokens,
476
- sources,
477
- followups,
478
- streaming,
479
- onFollowup,
480
- messageId,
481
- onFeedback,
482
- onNavigate,
483
- }) {
484
- const thinking = tokens.length === 0;
485
- const [vote, setVote] = useState(null); // 'up' | 'down' | null
486
- const [copied, setCopied] = useState(false);
487
-
488
- const onAction = (ic) => {
489
- if (ic === 'copy') {
490
- try {
491
- navigator.clipboard?.writeText(tokensToPlain(tokens));
492
- setCopied(true);
493
- setTimeout(() => setCopied(false), 1400);
494
- } catch {
495
- /* clipboard blocked — no-op */
496
- }
497
- return;
498
- }
499
- if (ic === 'thumbs-up' || ic === 'thumbs-down') {
500
- const v = ic === 'thumbs-up' ? 'up' : 'down';
501
- const next = vote === v ? null : v; // toggle off if re-clicked
502
- setVote(next);
503
- onFeedback?.(messageId, next); // null = retract
504
- }
505
- };
506
- return (
507
- <div className="velu-chatbot__msg velu-chatbot__msg--ai">
508
- <Cluster
509
- space="var(--s-3)"
510
- align="center"
511
- className={`velu-chatbot__role${thinking ? ' is-thinking' : ''}`}
512
- >
513
- {thinking ? <BlocksWave /> : resolveIcon('sparkles', { size: '1em' })}
514
- <span>{thinking ? 'Velu is thinking' : 'Velu'}</span>
515
- </Cluster>
516
-
517
- {!thinking && (
518
- <div className="velu-chatbot__answer">
519
- {renderStream(tokens, sources, onNavigate)}
520
- {streaming && <span className="velu-chatbot__caret" />}
521
-
522
- {!streaming && sources?.length > 0 && (
523
- <Stack space="var(--s-4)" className="velu-chatbot__sources">
524
- <span className="velu-chatbot__eyebrow">Sources</span>
525
- {sources.map((s) => (
526
- <a
527
- key={s.num}
528
- className="velu-chatbot__source"
529
- href={sourceHref(s) || '#'}
530
- onClick={(e) => onSourceClick(e, s, onNavigate)}
531
- >
532
- <span className="velu-chatbot__source-num">{s.num}</span>
533
- <span className="velu-chatbot__source-meta">
534
- <span className="velu-chatbot__source-title">{citeTitle(s)}</span>
535
- <span className="velu-chatbot__source-path">{s.path}</span>
536
- </span>
537
- </a>
538
- ))}
539
- </Stack>
540
- )}
541
-
542
- {!streaming && followups?.length > 0 && (
543
- <Stack space="var(--s-4)" className="velu-chatbot__followups">
544
- <span className="velu-chatbot__eyebrow">Follow up</span>
545
- {followups.map((f, i) => (
546
- <button
547
- key={i}
548
- type="button"
549
- className="velu-chatbot__followup"
550
- onClick={() => onFollowup(f)}
551
- >
552
- <span>{f}</span>
553
- {resolveIcon('arrow-right', { size: '1em' })}
554
- </button>
555
- ))}
556
- </Stack>
557
- )}
558
- </div>
559
- )}
560
-
561
- {!streaming && !thinking && (
562
- <Cluster space="0" className="velu-chatbot__msg-actions">
563
- {['copy', 'thumbs-up', 'thumbs-down'].map((ic) => {
564
- const active =
565
- (ic === 'thumbs-up' && vote === 'up') ||
566
- (ic === 'thumbs-down' && vote === 'down');
567
- const label = ic === 'copy' && copied ? 'check' : ic;
568
- return (
569
- <button
570
- key={ic}
571
- type="button"
572
- className={`velu-chatbot__msg-action${active ? ' is-active' : ''}`}
573
- aria-label={ic}
574
- aria-pressed={ic === 'copy' ? undefined : active}
575
- onClick={() => onAction(ic)}
576
- >
577
- {resolveIcon(label, { size: '1em' })}
578
- </button>
579
- );
580
- })}
581
- </Cluster>
582
- )}
583
- </div>
584
- );
585
- }
586
-
587
- /* ── Composer ───────────────────────────────────────────────────────── */
588
- function Composer({ value, onChange, onSubmit, disabled }) {
589
- const taRef = useRef(null);
590
- useEffect(() => {
591
- const ta = taRef.current;
592
- if (!ta) return;
593
- ta.style.height = 'auto';
594
- ta.style.height = `${ta.scrollHeight}px`;
595
- }, [value]);
596
-
597
- const active = value.trim().length > 0 && !disabled;
598
- const onKeyDown = (e) => {
599
- if (e.key === 'Enter' && !e.shiftKey) {
600
- e.preventDefault();
601
- if (active) onSubmit();
602
- }
603
- };
604
-
605
- return (
606
- <div className="velu-chatbot__composer">
607
- <div className="velu-chatbot__input-wrap">
608
- <textarea
609
- ref={taRef}
610
- className="velu-chatbot__input"
611
- placeholder="Ask anything about the docs…"
612
- rows={1}
613
- value={value}
614
- onChange={(e) => onChange(e.target.value)}
615
- onKeyDown={onKeyDown}
616
- />
617
- <button
618
- type="button"
619
- className={`velu-chatbot__send${active ? ' is-active' : ''}`}
620
- onClick={onSubmit}
621
- disabled={!active}
622
- aria-label="Send"
623
- >
624
- {resolveIcon('arrow-up', { size: '1em' })}
625
- </button>
626
- </div>
627
- <div className="velu-chatbot__foot">
628
- <kbd>Enter</kbd> to send · <kbd>⇧ Enter</kbd> for newline
629
- </div>
630
- </div>
631
- );
632
- }
633
-
634
- /* ── Panel ──────────────────────────────────────────────────────────── */
635
- export default function Chatbot({
636
- open = false,
637
- seedQuestion,
638
- onClose,
639
- ask,
640
- onFeedback,
641
- onNavigate,
642
- listHistory,
643
- loadConversation,
644
- suggestions,
645
- className = '',
646
- ...rest
647
- }) {
648
- const [messages, setMessages] = useState([]);
649
- const [input, setInput] = useState('');
650
- const [historyOpen, setHistoryOpen] = useState(false);
651
- const [history, setHistory] = useState([]);
652
- const [historyLoading, setHistoryLoading] = useState(false);
653
- const bodyRef = useRef(null);
654
- const streamingRef = useRef(false);
655
- const lastSeedRef = useRef(undefined);
656
- // Real-backend session: conversation id threads turns; the controller lets
657
- // a new-chat / close / unmount abort an in-flight stream.
658
- const convoRef = useRef(undefined);
659
- const abortRef = useRef(null);
660
-
661
- useEffect(() => {
662
- const el = bodyRef.current;
663
- if (el) el.scrollTop = el.scrollHeight;
664
- }, [messages]);
665
-
666
- // Merge a patch into the last (assistant) message.
667
- const patchLast = useCallback((patch) => {
668
- setMessages((prev) => {
669
- if (!prev.length) return prev;
670
- const out = prev.slice();
671
- out[out.length - 1] = { ...out[out.length - 1], ...patch };
672
- return out;
673
- });
674
- }, []);
675
-
676
- // Stop whatever is in flight — a canned setTimeout chain ({ timer }) or a
677
- // real fetch stream (an AbortController). Safe to call any time.
678
- const cancelInFlight = useCallback(() => {
679
- const a = abortRef.current;
680
- if (a) {
681
- if (a.timer) clearTimeout(a.timer);
682
- if (typeof a.abort === 'function') a.abort();
683
- }
684
- abortRef.current = null;
685
- streamingRef.current = false;
686
- }, []);
687
-
688
- // Real history: fetch the visitor's conversations when the panel opens.
689
- // Without a backend (dev preview) the canned demo entries show instead.
690
- useEffect(() => {
691
- if (!historyOpen) return;
692
- if (!listHistory) {
693
- setHistory(HISTORY.map((h) => ({ ...h, demo: true })));
694
- return;
695
- }
696
- let stale = false;
697
- setHistoryLoading(true);
698
- listHistory()
699
- .then((items) => {
700
- if (stale) return;
701
- setHistory(items.map((c) => ({
702
- id: c.conversationId,
703
- title: c.title,
704
- when: whenLabel(c.updatedAt),
705
- conversation: c,
706
- })));
707
- })
708
- .catch(() => { if (!stale) setHistory([]); })
709
- .finally(() => { if (!stale) setHistoryLoading(false); });
710
- return () => { stale = true; };
711
- }, [historyOpen, listHistory]);
712
-
713
- // Resume a past conversation: load its turns into the panel and thread the
714
- // conversation id so follow-up asks continue it.
715
- const openConversation = useCallback(
716
- async (item) => {
717
- setHistoryOpen(false);
718
- if (!item.conversation || !loadConversation) return; // demo entry
719
- cancelInFlight();
720
- try {
721
- const { conversationId, messages: msgs } = await loadConversation(item.conversation);
722
- convoRef.current = conversationId;
723
- setMessages(msgs.map((m) =>
724
- m.role === 'user'
725
- ? { role: 'user', text: m.content }
726
- : {
727
- role: 'ai',
728
- tokens: textToTokens(m.content),
729
- sources: m.citations || [],
730
- followups: [],
731
- streaming: false,
732
- messageId: m.messageId,
733
- },
734
- ));
735
- } catch {
736
- /* keep the current chat if the load fails */
737
- }
738
- },
739
- [loadConversation, cancelInFlight],
740
- );
741
-
742
- const send = useCallback(
743
- async (text) => {
744
- const prompt = String(text ?? '').trim();
745
- if (!prompt || streamingRef.current) return;
746
- setInput('');
747
-
748
- // ── No backend wired → canned, fake-streamed demo answer ──────────
749
- if (!ask) {
750
- const answer = buildAnswer();
751
- const fullTokens = tokenizeAnswer(answer);
752
- setMessages((prev) => [
753
- ...prev,
754
- { role: 'user', text: prompt },
755
- {
756
- role: 'ai',
757
- tokens: [],
758
- sources: answer.sources,
759
- followups: answer.followups,
760
- streaming: true,
761
- },
762
- ]);
763
- streamingRef.current = true;
764
- let i = 0;
765
- const step = () => {
766
- i = Math.min(fullTokens.length, i + 1);
767
- patchLast({
768
- tokens: fullTokens.slice(0, i),
769
- streaming: i < fullTokens.length,
770
- });
771
- if (i < fullTokens.length) {
772
- abortRef.current = { timer: setTimeout(step, 28) };
773
- } else {
774
- streamingRef.current = false;
775
- abortRef.current = null;
776
- }
777
- };
778
- abortRef.current = { timer: setTimeout(step, 1400) }; // "thinking" pause
779
- return;
780
- }
781
-
782
- // ── Real backend → stream deltas + citations from `ask` ───────────
783
- setMessages((prev) => [
784
- ...prev,
785
- { role: 'user', text: prompt },
786
- { role: 'ai', tokens: [], sources: [], followups: [], streaming: true },
787
- ]);
788
- streamingRef.current = true;
789
- const controller =
790
- typeof AbortController !== 'undefined' ? new AbortController() : null;
791
- abortRef.current = controller;
792
-
793
- let acc = '';
794
- let sources = [];
795
- let messageId;
796
- try {
797
- for await (const ev of ask(prompt, {
798
- conversationId: convoRef.current,
799
- signal: controller?.signal,
800
- })) {
801
- if (ev.conversationId) convoRef.current = ev.conversationId;
802
- if (ev.messageId) messageId = ev.messageId;
803
- if (ev.delta) acc += ev.delta;
804
- if (ev.citations) {
805
- sources = ev.citations.map((c, idx) => ({
806
- num: c.num ?? idx + 1,
807
- title: c.title,
808
- path: c.path ?? c.route_path,
809
- url: c.url,
810
- }));
811
- }
812
- patchLast({ tokens: textToTokens(acc), sources, streaming: true, messageId });
813
- }
814
- patchLast({ tokens: textToTokens(acc), sources, streaming: false, messageId });
815
- } catch (err) {
816
- if (controller?.signal?.aborted) {
817
- patchLast({ streaming: false });
818
- } else {
819
- patchLast({
820
- tokens: textToTokens(
821
- acc || 'Sorry — I couldn’t reach the assistant. Please try again.'
822
- ),
823
- streaming: false,
824
- error: true,
825
- });
826
- }
827
- } finally {
828
- streamingRef.current = false;
829
- abortRef.current = null;
830
- }
831
- },
832
- [ask, patchLast]
833
- );
834
-
835
- // Abort any in-flight stream when the panel unmounts.
836
- useEffect(() => () => cancelInFlight(), [cancelInFlight]);
837
-
838
- // A new seedQuestion (from the page's AskBar) sends the first message.
839
- useEffect(() => {
840
- if (open && seedQuestion && seedQuestion !== lastSeedRef.current) {
841
- lastSeedRef.current = seedQuestion;
842
- send(seedQuestion);
843
- }
844
- }, [open, seedQuestion, send]);
845
-
846
- const newChat = () => {
847
- cancelInFlight();
848
- convoRef.current = undefined; // fresh conversation thread
849
- setMessages([]);
850
- setInput('');
851
- setHistoryOpen(false);
852
- };
853
-
854
- // Drag-to-dismiss (mobile bottom-sheet only). Pointer events are
855
- // attached to the drag-handle element below; the handle's pointer
856
- // capture keeps the events flowing even if the finger slides off
857
- // the handle into the body. While dragging we apply an inline
858
- // `transform: translateY(...)` to the panel, overriding the CSS-
859
- // controlled transform. On release, if the drag distance exceeded
860
- // the threshold we call onClose() and the panel slides the rest of
861
- // the way down via the CSS transition; otherwise we drop the
862
- // inline transform and the panel snaps back via the same
863
- // transition.
864
- const dragRef = useRef({ active: false, startY: 0, currentY: 0 });
865
- const [dragOffset, setDragOffset] = useState(0);
866
- // Drag-to-dismiss is active wherever the chatbot is rendered as a
867
- // bottom sheet — same threshold as the @container query in
868
- // chatbot.css (< 1024px covers both narrow / tablet and mobile).
869
- const isBottomSheet = () =>
870
- typeof window !== 'undefined' &&
871
- window.matchMedia('(max-width: 1024px)').matches;
872
- const onDragStart = (e) => {
873
- if (!isBottomSheet()) return;
874
- dragRef.current = { active: true, startY: e.clientY, currentY: 0 };
875
- e.currentTarget.setPointerCapture?.(e.pointerId);
876
- };
877
- const onDragMove = (e) => {
878
- if (!dragRef.current.active) return;
879
- const delta = Math.max(0, e.clientY - dragRef.current.startY);
880
- dragRef.current.currentY = delta;
881
- setDragOffset(delta);
882
- };
883
- const onDragEnd = () => {
884
- if (!dragRef.current.active) return;
885
- dragRef.current.active = false;
886
- const shouldClose = dragRef.current.currentY > 80;
887
- setDragOffset(0);
888
- if (shouldClose) onClose?.();
889
- };
890
- // Reset any residual drag offset whenever the panel closes from
891
- // outside (scrim click, X button, etc.) so a re-open starts clean.
892
- useEffect(() => {
893
- if (!open) setDragOffset(0);
894
- }, [open]);
895
-
896
- const cls = `velu-chatbot${open ? ' velu-chatbot--open' : ''} ${className}`.trim();
897
- const dragStyle =
898
- dragOffset > 0
899
- ? { transform: `translateY(${dragOffset}px)`, transition: 'none' }
900
- : undefined;
901
-
902
- return (
903
- <aside
904
- className={cls}
905
- aria-hidden={!open}
906
- aria-label="Ask AI"
907
- style={dragStyle}
908
- {...rest}
909
- >
910
- {/* Drag handle — mobile only (CSS in chatbot.css). The pill
911
- visual + the touch-capturing strip across the top of the
912
- sheet. Pointer events here drive the drag-to-dismiss
913
- gesture above. */}
914
- <div
915
- className="velu-chatbot__drag-handle"
916
- onPointerDown={onDragStart}
917
- onPointerMove={onDragMove}
918
- onPointerUp={onDragEnd}
919
- onPointerCancel={onDragEnd}
920
- aria-hidden="true"
921
- />
922
- <ChatHeader
923
- onClose={onClose}
924
- onNew={newChat}
925
- onHistory={() => setHistoryOpen((o) => !o)}
926
- historyOpen={historyOpen}
927
- />
928
-
929
- {historyOpen && (
930
- <HistoryPanel
931
- items={history}
932
- loading={historyLoading}
933
- onClose={() => setHistoryOpen(false)}
934
- onPick={openConversation}
935
- />
936
- )}
937
-
938
- <div className="velu-chatbot__body" ref={bodyRef}>
939
- {messages.length === 0 ? (
940
- <Welcome onPick={(t) => send(t)} suggestions={suggestions} />
941
- ) : (
942
- <Stack space="var(--s1)">
943
- {messages.map((m, i) =>
944
- m.role === 'user' ? (
945
- <UserMsg key={i} text={m.text} />
946
- ) : (
947
- <AiMsg
948
- key={i}
949
- tokens={m.tokens}
950
- sources={m.sources}
951
- followups={m.followups}
952
- streaming={m.streaming}
953
- messageId={m.messageId}
954
- onFeedback={onFeedback}
955
- onNavigate={onNavigate}
956
- onFollowup={(f) => send(f)}
957
- />
958
- ),
959
- )}
960
- </Stack>
961
- )}
962
- </div>
963
-
964
- <Composer
965
- value={input}
966
- onChange={setInput}
967
- onSubmit={() => send(input)}
968
- disabled={streamingRef.current}
969
- />
970
- </aside>
971
- );
972
- }
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
+ /* Flatten the token stream back to a markdown string (code → `…`, citation →
199
+ [n]) so the assistant's markdown answer can be rendered with formatting. */
200
+ function tokensToMarkdown(tokens) {
201
+ return tokens
202
+ .map((tk) => (tk.kind === 'cite' ? `[${tk.n}]` : tk.kind === 'code' ? `\`${tk.v}\`` : tk.v))
203
+ .join('');
204
+ }
205
+
206
+ const CITE_RE = /\[(\d+)\]/;
207
+ const INLINE_RULES = [
208
+ { re: /\*\*([^*]+)\*\*/, type: 'bold' },
209
+ { re: /__([^_]+)__/, type: 'bold' },
210
+ { re: /\*([^*\n]+)\*/, type: 'italic' },
211
+ { re: /(?<![A-Za-z0-9_])_([^_\n]+)_(?![A-Za-z0-9_])/, type: 'italic' },
212
+ { re: /`([^`]+)`/, type: 'code' },
213
+ { re: /\[([^\]]+)\]\(([^)]+)\)/, type: 'link' },
214
+ { re: CITE_RE, type: 'cite' },
215
+ ];
216
+
217
+ /* Inline markdown → React nodes: **bold**, *italic*, `code`, [text](url) links,
218
+ and [n] citation chips. Builds React elements (never innerHTML) so it's XSS-safe. */
219
+ function renderInline(text, sources, onNavigate, keyBase) {
220
+ const out = [];
221
+ let rest = text;
222
+ let k = 0;
223
+ while (rest) {
224
+ let best = null;
225
+ for (const rule of INLINE_RULES) {
226
+ const m = rule.re.exec(rest);
227
+ if (m && (best === null || m.index < best.m.index)) best = { rule, m };
228
+ }
229
+ if (!best) {
230
+ out.push(<React.Fragment key={`${keyBase}-${k++}`}>{rest}</React.Fragment>);
231
+ break;
232
+ }
233
+ const { rule, m } = best;
234
+ if (m.index > 0) out.push(<React.Fragment key={`${keyBase}-${k++}`}>{rest.slice(0, m.index)}</React.Fragment>);
235
+ const key = `${keyBase}-${k++}`;
236
+ if (rule.type === 'bold') out.push(<strong key={key}>{m[1]}</strong>);
237
+ else if (rule.type === 'italic') out.push(<em key={key}>{m[1]}</em>);
238
+ else if (rule.type === 'code') out.push(<code key={key}>{m[1]}</code>);
239
+ else if (rule.type === 'link') out.push(
240
+ <a key={key} href={m[2]} target="_blank" rel="noopener noreferrer">{m[1]}</a>,
241
+ );
242
+ else if (rule.type === 'cite') {
243
+ const n = Number(m[1]);
244
+ const src = sources?.find((s) => s.num === n);
245
+ out.push(
246
+ <a
247
+ key={key}
248
+ className="velu-chatbot__cite"
249
+ href={sourceHref(src) || '#'}
250
+ onClick={(e) => onSourceClick(e, src, onNavigate)}
251
+ title={citeTitle(src)}
252
+ >
253
+ {n}
254
+ </a>,
255
+ );
256
+ }
257
+ rest = rest.slice(m.index + m[0].length);
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /* Block-level markdown → React: headings, bullet/numbered lists, fenced code
263
+ blocks, blockquotes, and paragraphs (each rendered through renderInline). */
264
+ function renderStream(tokens, sources, onNavigate) {
265
+ const text = tokensToMarkdown(tokens);
266
+ const lines = text.replace(/\r/g, '').split('\n');
267
+ const blocks = [];
268
+ let i = 0;
269
+ let b = 0;
270
+ const listRe = /^\s*([-*+]|\d+\.)\s+/;
271
+ while (i < lines.length) {
272
+ const line = lines[i];
273
+ if (!line.trim()) { i++; continue; }
274
+
275
+ if (line.trim().startsWith('```')) {
276
+ const buf = [];
277
+ i++;
278
+ while (i < lines.length && !lines[i].trim().startsWith('```')) { buf.push(lines[i]); i++; }
279
+ i++;
280
+ blocks.push(<pre key={`b${b++}`} className="velu-chatbot__pre"><code>{buf.join('\n')}</code></pre>);
281
+ continue;
282
+ }
283
+
284
+ const h = /^(#{1,6})\s+(.*)$/.exec(line);
285
+ if (h) {
286
+ const lvl = Math.min(h[1].length + 2, 6);
287
+ const Tag = `h${lvl}`;
288
+ blocks.push(<Tag key={`b${b++}`}>{renderInline(h[2], sources, onNavigate, `b${b}`)}</Tag>);
289
+ i++;
290
+ continue;
291
+ }
292
+
293
+ if (listRe.test(line)) {
294
+ const ordered = /^\s*\d+\.\s+/.test(line);
295
+ const items = [];
296
+ while (i < lines.length && listRe.test(lines[i])) {
297
+ const content = lines[i].replace(listRe, '');
298
+ items.push(<li key={`li${b}-${i}`}>{renderInline(content, sources, onNavigate, `li${b}-${i}`)}</li>);
299
+ i++;
300
+ }
301
+ const ListTag = ordered ? 'ol' : 'ul';
302
+ blocks.push(<ListTag key={`b${b++}`}>{items}</ListTag>);
303
+ continue;
304
+ }
305
+
306
+ if (line.trim().startsWith('>')) {
307
+ const buf = [];
308
+ while (i < lines.length && lines[i].trim().startsWith('>')) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
309
+ blocks.push(<blockquote key={`b${b++}`}>{renderInline(buf.join(' '), sources, onNavigate, `bq${b}`)}</blockquote>);
310
+ continue;
311
+ }
312
+
313
+ const buf = [];
314
+ while (
315
+ i < lines.length && lines[i].trim()
316
+ && !listRe.test(lines[i]) && !/^#{1,6}\s/.test(lines[i])
317
+ && !lines[i].trim().startsWith('```') && !lines[i].trim().startsWith('>')
318
+ ) { buf.push(lines[i]); i++; }
319
+ const pnodes = [];
320
+ buf.forEach((ln, li) => {
321
+ if (li > 0) pnodes.push(<br key={`p${b}-br${li}`} />);
322
+ pnodes.push(...renderInline(ln, sources, onNavigate, `p${b}-${li}`));
323
+ });
324
+ blocks.push(<p key={`b${b++}`}>{pnodes}</p>);
325
+ }
326
+ return blocks;
327
+ }
328
+
329
+ /* ── Header ─────────────────────────────────────────────────────────── */
330
+ function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
331
+ return (
332
+ <Cluster
333
+ space="var(--s-2)"
334
+ justify="space-between"
335
+ align="center"
336
+ className="velu-chatbot__header"
337
+ >
338
+ <Cluster space="var(--s-2)" align="center">
339
+ <VeluMark />
340
+ <span className="velu-chatbot__brand">
341
+ <span className="velu-chatbot__brand-name">Velu</span>
342
+ <span className="velu-chatbot__brand-sep">/</span>
343
+ <span className="velu-chatbot__brand-title">Ask AI</span>
344
+ </span>
345
+ </Cluster>
346
+ <Cluster space="var(--s-5)" align="center">
347
+ <button
348
+ type="button"
349
+ className={`velu-chatbot__iconbtn${historyOpen ? ' is-active' : ''}`}
350
+ aria-label="History"
351
+ onClick={onHistory}
352
+ >
353
+ {resolveIcon('history', { size: '1em' })}
354
+ </button>
355
+ <button
356
+ type="button"
357
+ className="velu-chatbot__iconbtn"
358
+ aria-label="New chat"
359
+ onClick={onNew}
360
+ >
361
+ {resolveIcon('plus', { size: '1em' })}
362
+ </button>
363
+ <button
364
+ type="button"
365
+ className="velu-chatbot__iconbtn"
366
+ aria-label="Close"
367
+ onClick={onClose}
368
+ >
369
+ {resolveIcon('x', { size: '1em' })}
370
+ </button>
371
+ </Cluster>
372
+ </Cluster>
373
+ );
374
+ }
375
+
376
+ /* ── History overlay ────────────────────────────────────────────────── */
377
+
378
+ /* "Today, 10:24" / "Yesterday" / "Apr 28" for a conversation timestamp. */
379
+ function whenLabel(iso) {
380
+ if (!iso) return '';
381
+ const d = new Date(iso);
382
+ if (Number.isNaN(d.getTime())) return '';
383
+ const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
384
+ const days = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
385
+ if (days <= 0) {
386
+ return `Today, ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
387
+ }
388
+ if (days === 1) return 'Yesterday';
389
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
390
+ }
391
+
392
+ function HistoryPanel({ items, loading, onPick, onClose }) {
393
+ return (
394
+ <div className="velu-chatbot__history">
395
+ <Cluster
396
+ space="var(--s-2)"
397
+ justify="space-between"
398
+ align="center"
399
+ className="velu-chatbot__history-head"
400
+ >
401
+ <span className="velu-chatbot__eyebrow">Recent chats</span>
402
+ <button
403
+ type="button"
404
+ className="velu-chatbot__iconbtn"
405
+ aria-label="Close history"
406
+ onClick={onClose}
407
+ >
408
+ {resolveIcon('x', { size: '1em' })}
409
+ </button>
410
+ </Cluster>
411
+ <Stack as="div" space="var(--s-5)" className="velu-chatbot__history-list">
412
+ {loading ? (
413
+ <span className="velu-chatbot__history-empty">Loading…</span>
414
+ ) : items.length === 0 ? (
415
+ <span className="velu-chatbot__history-empty">No conversations yet.</span>
416
+ ) : (
417
+ items.map((h) => (
418
+ <button
419
+ key={h.id}
420
+ type="button"
421
+ className="velu-chatbot__history-item"
422
+ onClick={() => onPick(h)}
423
+ >
424
+ <span className="velu-chatbot__history-item-title">{h.title}</span>
425
+ <span className="velu-chatbot__history-item-when">{h.when}</span>
426
+ </button>
427
+ ))
428
+ )}
429
+ </Stack>
430
+ </div>
431
+ );
432
+ }
433
+
434
+ /* ── Welcome / empty state ──────────────────────────────────────────── */
435
+ function Welcome({ onPick, suggestions }) {
436
+ // Site-specific starter questions (velu.json assistant.suggestions, or
437
+ // publish-time generated); the canned demo list is the dev-preview fallback.
438
+ const items = suggestions?.length
439
+ ? suggestions.map((label) => ({ label, icon: 'sparkles' }))
440
+ : SUGGESTIONS;
441
+ return (
442
+ <Stack space="var(--s0)" className="velu-chatbot__welcome">
443
+ <p className="velu-chatbot__welcome-hi">Ask anything about the docs.</p>
444
+ <span className="velu-chatbot__eyebrow">Suggested</span>
445
+ <Stack space="var(--s-4)">
446
+ {items.map((s, i) => (
447
+ <button
448
+ key={i}
449
+ type="button"
450
+ className="velu-chatbot__suggest"
451
+ onClick={() => onPick(s.label)}
452
+ >
453
+ <Cluster space="var(--s-2)" align="center">
454
+ {resolveIcon(s.icon, { size: '1em' })}
455
+ <span>{s.label}</span>
456
+ </Cluster>
457
+ {resolveIcon('arrow-right', { size: '1em' })}
458
+ </button>
459
+ ))}
460
+ </Stack>
461
+ </Stack>
462
+ );
463
+ }
464
+
465
+ /* ── Messages ───────────────────────────────────────────────────────── */
466
+ function UserMsg({ text }) {
467
+ return (
468
+ <div className="velu-chatbot__msg velu-chatbot__msg--user">
469
+ <div className="velu-chatbot__bubble">{text}</div>
470
+ </div>
471
+ );
472
+ }
473
+
474
+ function AiMsg({
475
+ tokens,
476
+ sources,
477
+ followups,
478
+ streaming,
479
+ onFollowup,
480
+ messageId,
481
+ onFeedback,
482
+ onNavigate,
483
+ }) {
484
+ const thinking = tokens.length === 0;
485
+ const [vote, setVote] = useState(null); // 'up' | 'down' | null
486
+ const [copied, setCopied] = useState(false);
487
+
488
+ const onAction = (ic) => {
489
+ if (ic === 'copy') {
490
+ try {
491
+ navigator.clipboard?.writeText(tokensToPlain(tokens));
492
+ setCopied(true);
493
+ setTimeout(() => setCopied(false), 1400);
494
+ } catch {
495
+ /* clipboard blocked — no-op */
496
+ }
497
+ return;
498
+ }
499
+ if (ic === 'thumbs-up' || ic === 'thumbs-down') {
500
+ const v = ic === 'thumbs-up' ? 'up' : 'down';
501
+ const next = vote === v ? null : v; // toggle off if re-clicked
502
+ setVote(next);
503
+ onFeedback?.(messageId, next); // null = retract
504
+ }
505
+ };
506
+ return (
507
+ <div className="velu-chatbot__msg velu-chatbot__msg--ai">
508
+ <Cluster
509
+ space="var(--s-3)"
510
+ align="center"
511
+ className={`velu-chatbot__role${thinking ? ' is-thinking' : ''}`}
512
+ >
513
+ {thinking ? <BlocksWave /> : resolveIcon('sparkles', { size: '1em' })}
514
+ <span>{thinking ? 'Velu is thinking' : 'Velu'}</span>
515
+ </Cluster>
516
+
517
+ {!thinking && (
518
+ <div className="velu-chatbot__answer">
519
+ {renderStream(tokens, sources, onNavigate)}
520
+ {streaming && <span className="velu-chatbot__caret" />}
521
+
522
+ {!streaming && sources?.length > 0 && (
523
+ <Stack space="var(--s-4)" className="velu-chatbot__sources">
524
+ <span className="velu-chatbot__eyebrow">Sources</span>
525
+ {sources.map((s) => (
526
+ <a
527
+ key={s.num}
528
+ className="velu-chatbot__source"
529
+ href={sourceHref(s) || '#'}
530
+ onClick={(e) => onSourceClick(e, s, onNavigate)}
531
+ >
532
+ <span className="velu-chatbot__source-num">{s.num}</span>
533
+ <span className="velu-chatbot__source-meta">
534
+ <span className="velu-chatbot__source-title">{citeTitle(s)}</span>
535
+ <span className="velu-chatbot__source-path">{s.path}</span>
536
+ </span>
537
+ </a>
538
+ ))}
539
+ </Stack>
540
+ )}
541
+
542
+ {!streaming && followups?.length > 0 && (
543
+ <Stack space="var(--s-4)" className="velu-chatbot__followups">
544
+ <span className="velu-chatbot__eyebrow">Follow up</span>
545
+ {followups.map((f, i) => (
546
+ <button
547
+ key={i}
548
+ type="button"
549
+ className="velu-chatbot__followup"
550
+ onClick={() => onFollowup(f)}
551
+ >
552
+ <span>{f}</span>
553
+ {resolveIcon('arrow-right', { size: '1em' })}
554
+ </button>
555
+ ))}
556
+ </Stack>
557
+ )}
558
+ </div>
559
+ )}
560
+
561
+ {!streaming && !thinking && (
562
+ <Cluster space="0" className="velu-chatbot__msg-actions">
563
+ {['copy', 'thumbs-up', 'thumbs-down'].map((ic) => {
564
+ const active =
565
+ (ic === 'thumbs-up' && vote === 'up') ||
566
+ (ic === 'thumbs-down' && vote === 'down');
567
+ const label = ic === 'copy' && copied ? 'check' : ic;
568
+ return (
569
+ <button
570
+ key={ic}
571
+ type="button"
572
+ className={`velu-chatbot__msg-action${active ? ' is-active' : ''}`}
573
+ aria-label={ic}
574
+ aria-pressed={ic === 'copy' ? undefined : active}
575
+ onClick={() => onAction(ic)}
576
+ >
577
+ {resolveIcon(label, { size: '1em' })}
578
+ </button>
579
+ );
580
+ })}
581
+ </Cluster>
582
+ )}
583
+ </div>
584
+ );
585
+ }
586
+
587
+ /* ── Composer ───────────────────────────────────────────────────────── */
588
+ function Composer({ value, onChange, onSubmit, disabled }) {
589
+ const taRef = useRef(null);
590
+ useEffect(() => {
591
+ const ta = taRef.current;
592
+ if (!ta) return;
593
+ ta.style.height = 'auto';
594
+ ta.style.height = `${ta.scrollHeight}px`;
595
+ }, [value]);
596
+
597
+ const active = value.trim().length > 0 && !disabled;
598
+ const onKeyDown = (e) => {
599
+ if (e.key === 'Enter' && !e.shiftKey) {
600
+ e.preventDefault();
601
+ if (active) onSubmit();
602
+ }
603
+ };
604
+
605
+ return (
606
+ <div className="velu-chatbot__composer">
607
+ <div className="velu-chatbot__input-wrap">
608
+ <textarea
609
+ ref={taRef}
610
+ className="velu-chatbot__input"
611
+ placeholder="Ask anything about the docs…"
612
+ rows={1}
613
+ value={value}
614
+ onChange={(e) => onChange(e.target.value)}
615
+ onKeyDown={onKeyDown}
616
+ />
617
+ <button
618
+ type="button"
619
+ className={`velu-chatbot__send${active ? ' is-active' : ''}`}
620
+ onClick={onSubmit}
621
+ disabled={!active}
622
+ aria-label="Send"
623
+ >
624
+ {resolveIcon('arrow-up', { size: '1em' })}
625
+ </button>
626
+ </div>
627
+ <div className="velu-chatbot__foot">
628
+ <kbd>Enter</kbd> to send · <kbd>⇧ Enter</kbd> for newline
629
+ </div>
630
+ </div>
631
+ );
632
+ }
633
+
634
+ /* ── Panel ──────────────────────────────────────────────────────────── */
635
+ export default function Chatbot({
636
+ open = false,
637
+ seedQuestion,
638
+ onClose,
639
+ ask,
640
+ onFeedback,
641
+ onNavigate,
642
+ listHistory,
643
+ loadConversation,
644
+ suggestions,
645
+ className = '',
646
+ ...rest
647
+ }) {
648
+ const [messages, setMessages] = useState([]);
649
+ const [input, setInput] = useState('');
650
+ const [historyOpen, setHistoryOpen] = useState(false);
651
+ const [history, setHistory] = useState([]);
652
+ const [historyLoading, setHistoryLoading] = useState(false);
653
+ const bodyRef = useRef(null);
654
+ const streamingRef = useRef(false);
655
+ const lastSeedRef = useRef(undefined);
656
+ // Real-backend session: conversation id threads turns; the controller lets
657
+ // a new-chat / close / unmount abort an in-flight stream.
658
+ const convoRef = useRef(undefined);
659
+ const abortRef = useRef(null);
660
+
661
+ useEffect(() => {
662
+ const el = bodyRef.current;
663
+ if (el) el.scrollTop = el.scrollHeight;
664
+ }, [messages]);
665
+
666
+ // Merge a patch into the last (assistant) message.
667
+ const patchLast = useCallback((patch) => {
668
+ setMessages((prev) => {
669
+ if (!prev.length) return prev;
670
+ const out = prev.slice();
671
+ out[out.length - 1] = { ...out[out.length - 1], ...patch };
672
+ return out;
673
+ });
674
+ }, []);
675
+
676
+ // Stop whatever is in flight — a canned setTimeout chain ({ timer }) or a
677
+ // real fetch stream (an AbortController). Safe to call any time.
678
+ const cancelInFlight = useCallback(() => {
679
+ const a = abortRef.current;
680
+ if (a) {
681
+ if (a.timer) clearTimeout(a.timer);
682
+ if (typeof a.abort === 'function') a.abort();
683
+ }
684
+ abortRef.current = null;
685
+ streamingRef.current = false;
686
+ }, []);
687
+
688
+ // Real history: fetch the visitor's conversations when the panel opens.
689
+ // Without a backend (dev preview) the canned demo entries show instead.
690
+ useEffect(() => {
691
+ if (!historyOpen) return;
692
+ if (!listHistory) {
693
+ setHistory(HISTORY.map((h) => ({ ...h, demo: true })));
694
+ return;
695
+ }
696
+ let stale = false;
697
+ setHistoryLoading(true);
698
+ listHistory()
699
+ .then((items) => {
700
+ if (stale) return;
701
+ setHistory(items.map((c) => ({
702
+ id: c.conversationId,
703
+ title: c.title,
704
+ when: whenLabel(c.updatedAt),
705
+ conversation: c,
706
+ })));
707
+ })
708
+ .catch(() => { if (!stale) setHistory([]); })
709
+ .finally(() => { if (!stale) setHistoryLoading(false); });
710
+ return () => { stale = true; };
711
+ }, [historyOpen, listHistory]);
712
+
713
+ // Resume a past conversation: load its turns into the panel and thread the
714
+ // conversation id so follow-up asks continue it.
715
+ const openConversation = useCallback(
716
+ async (item) => {
717
+ setHistoryOpen(false);
718
+ if (!item.conversation || !loadConversation) return; // demo entry
719
+ cancelInFlight();
720
+ try {
721
+ const { conversationId, messages: msgs } = await loadConversation(item.conversation);
722
+ convoRef.current = conversationId;
723
+ setMessages(msgs.map((m) =>
724
+ m.role === 'user'
725
+ ? { role: 'user', text: m.content }
726
+ : {
727
+ role: 'ai',
728
+ tokens: textToTokens(m.content),
729
+ sources: m.citations || [],
730
+ followups: [],
731
+ streaming: false,
732
+ messageId: m.messageId,
733
+ },
734
+ ));
735
+ } catch {
736
+ /* keep the current chat if the load fails */
737
+ }
738
+ },
739
+ [loadConversation, cancelInFlight],
740
+ );
741
+
742
+ const send = useCallback(
743
+ async (text) => {
744
+ const prompt = String(text ?? '').trim();
745
+ if (!prompt || streamingRef.current) return;
746
+ setInput('');
747
+
748
+ // ── No backend wired → canned, fake-streamed demo answer ──────────
749
+ if (!ask) {
750
+ const answer = buildAnswer();
751
+ const fullTokens = tokenizeAnswer(answer);
752
+ setMessages((prev) => [
753
+ ...prev,
754
+ { role: 'user', text: prompt },
755
+ {
756
+ role: 'ai',
757
+ tokens: [],
758
+ sources: answer.sources,
759
+ followups: answer.followups,
760
+ streaming: true,
761
+ },
762
+ ]);
763
+ streamingRef.current = true;
764
+ let i = 0;
765
+ const step = () => {
766
+ i = Math.min(fullTokens.length, i + 1);
767
+ patchLast({
768
+ tokens: fullTokens.slice(0, i),
769
+ streaming: i < fullTokens.length,
770
+ });
771
+ if (i < fullTokens.length) {
772
+ abortRef.current = { timer: setTimeout(step, 28) };
773
+ } else {
774
+ streamingRef.current = false;
775
+ abortRef.current = null;
776
+ }
777
+ };
778
+ abortRef.current = { timer: setTimeout(step, 1400) }; // "thinking" pause
779
+ return;
780
+ }
781
+
782
+ // ── Real backend → stream deltas + citations from `ask` ───────────
783
+ setMessages((prev) => [
784
+ ...prev,
785
+ { role: 'user', text: prompt },
786
+ { role: 'ai', tokens: [], sources: [], followups: [], streaming: true },
787
+ ]);
788
+ streamingRef.current = true;
789
+ const controller =
790
+ typeof AbortController !== 'undefined' ? new AbortController() : null;
791
+ abortRef.current = controller;
792
+
793
+ let acc = '';
794
+ let sources = [];
795
+ let messageId;
796
+ try {
797
+ for await (const ev of ask(prompt, {
798
+ conversationId: convoRef.current,
799
+ signal: controller?.signal,
800
+ })) {
801
+ if (ev.conversationId) convoRef.current = ev.conversationId;
802
+ if (ev.messageId) messageId = ev.messageId;
803
+ if (ev.delta) acc += ev.delta;
804
+ if (ev.citations) {
805
+ sources = ev.citations.map((c, idx) => ({
806
+ num: c.num ?? idx + 1,
807
+ title: c.title,
808
+ path: c.path ?? c.route_path,
809
+ url: c.url,
810
+ }));
811
+ }
812
+ patchLast({ tokens: textToTokens(acc), sources, streaming: true, messageId });
813
+ }
814
+ patchLast({ tokens: textToTokens(acc), sources, streaming: false, messageId });
815
+ } catch (err) {
816
+ if (controller?.signal?.aborted) {
817
+ patchLast({ streaming: false });
818
+ } else {
819
+ patchLast({
820
+ tokens: textToTokens(
821
+ acc || 'Sorry — I couldn’t reach the assistant. Please try again.'
822
+ ),
823
+ streaming: false,
824
+ error: true,
825
+ });
826
+ }
827
+ } finally {
828
+ streamingRef.current = false;
829
+ abortRef.current = null;
830
+ }
831
+ },
832
+ [ask, patchLast]
833
+ );
834
+
835
+ // Abort any in-flight stream when the panel unmounts.
836
+ useEffect(() => () => cancelInFlight(), [cancelInFlight]);
837
+
838
+ // A new seedQuestion (from the page's AskBar) sends the first message.
839
+ useEffect(() => {
840
+ if (open && seedQuestion && seedQuestion !== lastSeedRef.current) {
841
+ lastSeedRef.current = seedQuestion;
842
+ send(seedQuestion);
843
+ }
844
+ }, [open, seedQuestion, send]);
845
+
846
+ const newChat = () => {
847
+ cancelInFlight();
848
+ convoRef.current = undefined; // fresh conversation thread
849
+ setMessages([]);
850
+ setInput('');
851
+ setHistoryOpen(false);
852
+ };
853
+
854
+ // Drag-to-dismiss (mobile bottom-sheet only). Pointer events are
855
+ // attached to the drag-handle element below; the handle's pointer
856
+ // capture keeps the events flowing even if the finger slides off
857
+ // the handle into the body. While dragging we apply an inline
858
+ // `transform: translateY(...)` to the panel, overriding the CSS-
859
+ // controlled transform. On release, if the drag distance exceeded
860
+ // the threshold we call onClose() and the panel slides the rest of
861
+ // the way down via the CSS transition; otherwise we drop the
862
+ // inline transform and the panel snaps back via the same
863
+ // transition.
864
+ const dragRef = useRef({ active: false, startY: 0, currentY: 0 });
865
+ const [dragOffset, setDragOffset] = useState(0);
866
+ // Drag-to-dismiss is active wherever the chatbot is rendered as a
867
+ // bottom sheet — same threshold as the @container query in
868
+ // chatbot.css (< 1024px covers both narrow / tablet and mobile).
869
+ const isBottomSheet = () =>
870
+ typeof window !== 'undefined' &&
871
+ window.matchMedia('(max-width: 1024px)').matches;
872
+ const onDragStart = (e) => {
873
+ if (!isBottomSheet()) return;
874
+ dragRef.current = { active: true, startY: e.clientY, currentY: 0 };
875
+ e.currentTarget.setPointerCapture?.(e.pointerId);
876
+ };
877
+ const onDragMove = (e) => {
878
+ if (!dragRef.current.active) return;
879
+ const delta = Math.max(0, e.clientY - dragRef.current.startY);
880
+ dragRef.current.currentY = delta;
881
+ setDragOffset(delta);
882
+ };
883
+ const onDragEnd = () => {
884
+ if (!dragRef.current.active) return;
885
+ dragRef.current.active = false;
886
+ const shouldClose = dragRef.current.currentY > 80;
887
+ setDragOffset(0);
888
+ if (shouldClose) onClose?.();
889
+ };
890
+ // Reset any residual drag offset whenever the panel closes from
891
+ // outside (scrim click, X button, etc.) so a re-open starts clean.
892
+ useEffect(() => {
893
+ if (!open) setDragOffset(0);
894
+ }, [open]);
895
+
896
+ const cls = `velu-chatbot${open ? ' velu-chatbot--open' : ''} ${className}`.trim();
897
+ const dragStyle =
898
+ dragOffset > 0
899
+ ? { transform: `translateY(${dragOffset}px)`, transition: 'none' }
900
+ : undefined;
901
+
902
+ return (
903
+ <aside
904
+ className={cls}
905
+ aria-hidden={!open}
906
+ aria-label="Ask AI"
907
+ style={dragStyle}
908
+ {...rest}
909
+ >
910
+ {/* Drag handle — mobile only (CSS in chatbot.css). The pill
911
+ visual + the touch-capturing strip across the top of the
912
+ sheet. Pointer events here drive the drag-to-dismiss
913
+ gesture above. */}
914
+ <div
915
+ className="velu-chatbot__drag-handle"
916
+ onPointerDown={onDragStart}
917
+ onPointerMove={onDragMove}
918
+ onPointerUp={onDragEnd}
919
+ onPointerCancel={onDragEnd}
920
+ aria-hidden="true"
921
+ />
922
+ <ChatHeader
923
+ onClose={onClose}
924
+ onNew={newChat}
925
+ onHistory={() => setHistoryOpen((o) => !o)}
926
+ historyOpen={historyOpen}
927
+ />
928
+
929
+ {historyOpen && (
930
+ <HistoryPanel
931
+ items={history}
932
+ loading={historyLoading}
933
+ onClose={() => setHistoryOpen(false)}
934
+ onPick={openConversation}
935
+ />
936
+ )}
937
+
938
+ <div className="velu-chatbot__body" ref={bodyRef}>
939
+ {messages.length === 0 ? (
940
+ <Welcome onPick={(t) => send(t)} suggestions={suggestions} />
941
+ ) : (
942
+ <Stack space="var(--s1)">
943
+ {messages.map((m, i) =>
944
+ m.role === 'user' ? (
945
+ <UserMsg key={i} text={m.text} />
946
+ ) : (
947
+ <AiMsg
948
+ key={i}
949
+ tokens={m.tokens}
950
+ sources={m.sources}
951
+ followups={m.followups}
952
+ streaming={m.streaming}
953
+ messageId={m.messageId}
954
+ onFeedback={onFeedback}
955
+ onNavigate={onNavigate}
956
+ onFollowup={(f) => send(f)}
957
+ />
958
+ ),
959
+ )}
960
+ </Stack>
961
+ )}
962
+ </div>
963
+
964
+ <Composer
965
+ value={input}
966
+ onChange={setInput}
967
+ onSubmit={() => send(input)}
968
+ disabled={streamingRef.current}
969
+ />
970
+ </aside>
971
+ );
972
+ }